Repository: steven-tey/novel Branch: main Commit: fa95098e6647 Files: 95 Total size: 196.3 KB Directory structure: gitextract_5zn3qsxd/ ├── .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/ │ ├── .gitignore │ ├── .prettierignore │ ├── app/ │ │ ├── api/ │ │ │ ├── generate/ │ │ │ │ └── route.ts │ │ │ └── upload/ │ │ │ └── route.ts │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── providers.tsx │ ├── biome.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 │ ├── components.json │ ├── 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-workspace.yaml ├── prettier.config.js └── turbo.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .changeset/config.json ================================================ { "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", "changelog": [ "@changesets/changelog-github", { "repo": "steven-tey/novel" } ], "commit": false, "fixed": [], "linked": [], "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": [ "novel-next-app" ] } ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: andrewdoro ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: 🐞 Bug Report description: Create a bug report to help us improve title: "bug: " labels: ["🐞❔ unconfirmed bug"] body: - type: textarea attributes: label: Provide environment information description: | Run this command in your project root and paste the results in a code block: ```bash npx envinfo --system --binaries ``` validations: required: true - type: textarea attributes: label: Describe the bug description: A clear and concise description of the bug, as well as what you expected to happen when encountering it. validations: required: true - type: input attributes: label: Link to reproduction description: Please provide a link to a reproduction of the bug. Issues without a reproduction repo may be ignored. validations: required: true - type: textarea attributes: label: To reproduce description: Describe how to reproduce your bug. Steps, code snippets, reproduction repos etc. validations: required: true - type: textarea attributes: label: Additional information description: Add any other information related to the bug here, screenshots if applicable. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ # This template is heavily inspired by the Next.js's template: # See here: https://github.com/vercel/next.js/tree/canary/.github/ISSUE_TEMPLATE name: 🛠 Feature Request description: Create a feature request for the core packages title: "feat: " labels: ["✨ enhancement"] body: - type: markdown attributes: value: | Thank you for taking the time to file a feature request. Please fill out this form as completely as possible. - type: textarea attributes: label: Describe the feature you'd like to request description: Please describe the feature as clear and concise as possible. Remember to add context as to why you believe this feature is needed. validations: required: true - type: textarea attributes: label: Describe the solution you'd like to see description: Please describe the solution you would like to see. Adding example usage is a good way to provide context. validations: required: true - type: textarea attributes: label: Additional information description: Add any other information related to the feature here. If your feature request is related to any issues or discussions, link them here. ================================================ FILE: .github/workflows/release.yaml ================================================ # This workflow will release the packages with Changesets name: 🚀 Release on: push: branches: - main workflow_dispatch: concurrency: ${{ github.workflow }}-${{ github.ref }} env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} jobs: release: name: 🚀 Release strategy: matrix: os: [ubuntu-latest] node-version: [lts/*] pnpm-version: [latest] runs-on: ${{ matrix.os }} steps: - name: ⬇️ Checkout id: checkout uses: actions/checkout@v2.3.3 with: token: ${{ env.GITHUB_TOKEN }} fetch-depth: 0 - name: 🟢 Setup node id: setup-node uses: actions/setup-node@v2 with: node-version: ${{ matrix.node-version }} - name: 🥡 Setup pnpm id: setup-pnpm uses: pnpm/action-setup@v2.1.0 with: version: ${{ matrix.pnpm-version }} run_install: false - name: 🎈 Get pnpm store directory id: get-pnpm-cache-dir run: | echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" - name: 🔆 Cache pnpm modules uses: actions/cache@v3 id: pnpm-cache with: path: ${{ steps.get-pnpm-cache-dir.outputs.pnpm_cache_dir }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- - name: 🧩 Install Dependencies id: install-dependencies run: pnpm install - name: 🏗️ Build id: build-the-mono-repo run: pnpm build - name: 📣 Create Release Pull Request or Publish to npm id: changesets uses: changesets/action@v1 with: title: "chore(release): version packages 🦋" publish: pnpm publish:packages version: pnpm version:packages commit: "chore(release): version packages 🦋 [skip ci]" env: GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }} NPM_TOKEN: ${{ env.NPM_TOKEN }} ================================================ FILE: .gitignore ================================================ # dependencies /node_modules /.pnp .pnp.js node_modules packages/*/node_modules apps/*/node_modules .next # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug .pnpm-debug.log* # other lockfiles that's not pnpm-lock.yaml package-lock.json yarn.lock # local env files .env .env*.local # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts # intellij .idea dist/** /dist packages/*/dist .turbo /test-results/ /playwright-report/ /playwright/.cache/ ================================================ FILE: .husky/commit-msg ================================================ pnpm commitlint --edit $1 ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "yoavbls.pretty-ts-errors", "bradlc.vscode-tailwindcss", "biomejs.biome" ] } ================================================ FILE: .vscode/settings.json ================================================ { "editor.codeActionsOnSave": { "source.organizeImports.biome": "explicit", "source.fixAll.biome": "explicit", // "quickfix.biome": "explicit" }, "editor.defaultFormatter": "biomejs.biome", "editor.formatOnSave": true, "tailwindCSS.experimental.classRegex": [ ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] ], "typescript.enablePromptUseWorkspaceTsdk": true, "typescript.tsdk": "node_modules/typescript/lib", "typescript.preferences.autoImportFileExcludePatterns": [ "next/router.d.ts", "next/dist/client/router.d.ts" ], "[typescriptreact]": { "editor.defaultFormatter": "biomejs.biome" }, "[typescript]": { "editor.defaultFormatter": "biomejs.biome" }, "[json]": { "editor.defaultFormatter": "vscode.json-language-features" } } ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ Novel is a Notion-style WYSIWYG editor with AI-powered autocompletions.

Novel

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

Hacker News License Novel.sh's GitHub repo

Introduction · Deploy Your Own · Setting Up Locally · Tech Stack · Contributing · License


## Docs (WIP) https://novel.sh/docs/introduction ## Introduction [Novel](https://novel.sh/) is a Notion-style WYSIWYG editor with AI-powered autocompletions. https://github.com/steven-tey/novel/assets/28986134/2099877f-4f2b-4b1c-8782-5d803d63be5c
## Deploy Your Own You can deploy your own version of Novel to Vercel with one click: [![Deploy with Vercel](https://vercel.com/button)](https://stey.me/novel-deploy) ## Setting Up Locally To set up Novel locally, you'll need to clone the repository and set up the following environment variables: - `OPENAI_API_KEY` – your OpenAI API key (you can get one [here](https://platform.openai.com/account/api-keys)) - `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) 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. To run the app locally, you can run the following commands: ``` pnpm i pnpm dev ``` ## Cross-framework support While Novel is built for React, we also have a few community-maintained packages for non-React frameworks: - Svelte: https://novel.sh/svelte - Vue: https://novel.sh/vue ## VSCode Extension Thanks to @bennykok, Novel also has a VSCode Extension: https://novel.sh/vscode https://github.com/steven-tey/novel/assets/28986134/58ebf7e3-cdb3-43df-878b-119e304f7373 ## Tech Stack Novel is built on the following stack: - [Next.js](https://nextjs.org/) – framework - [Tiptap](https://tiptap.dev/) – text editor - [OpenAI](https://openai.com/) - AI completions - [Vercel AI SDK](https://sdk.vercel.ai/docs) – AI library - [Vercel](https://vercel.com) – deployments - [TailwindCSS](https://tailwindcss.com/) – styles - [Cal Sans](https://github.com/calcom/font) – font ## Contributing Here's how you can contribute: - [Open an issue](https://github.com/steven-tey/novel/issues) if you believe you've encountered a bug. - Make a [pull request](https://github.com/steven-tey/novel/pull) to add new features/make quality-of-life improvements/fix bugs. ## Repo Activity ![Novel.sh repo activity – generated by Axiom](https://repobeats.axiom.co/api/embed/2ebdaa143b0ad6e7c2ee23151da7b37f67da0b36.svg) ## License Licensed under the [Apache-2.0 license](https://github.com/steven-tey/novel/blob/main/LICENSE). ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Supported Versions We release patches for security vulnerabilities. | Version | Supported | | ------- | ------------------ | | 0.2.x | :white_check_mark: | | 0.1.x | :x: | ## Reporting a Vulnerability 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. ================================================ FILE: apps/web/.gitignore ================================================ .vercel ================================================ FILE: apps/web/.prettierignore ================================================ pnpm-lock.yaml yarn.lock node_modules .next ================================================ FILE: apps/web/app/api/generate/route.ts ================================================ import { openai } from "@ai-sdk/openai"; import { Ratelimit } from "@upstash/ratelimit"; import { kv } from "@vercel/kv"; import { streamText } from "ai"; import { match } from "ts-pattern"; // IMPORTANT! Set the runtime to edge: https://vercel.com/docs/functions/edge-functions/edge-runtime export const runtime = "edge"; export async function POST(req: Request): Promise { // Check if the OPENAI_API_KEY is set, if not return 400 if (!process.env.OPENAI_API_KEY || process.env.OPENAI_API_KEY === "") { return new Response("Missing OPENAI_API_KEY - make sure to add it to your .env file.", { status: 400, }); } if (process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN) { const ip = req.headers.get("x-forwarded-for"); const ratelimit = new Ratelimit({ redis: kv, limiter: Ratelimit.slidingWindow(50, "1 d"), }); const { success, limit, reset, remaining } = await ratelimit.limit(`novel_ratelimit_${ip}`); if (!success) { return new Response("You have reached your request limit for the day.", { status: 429, headers: { "X-RateLimit-Limit": limit.toString(), "X-RateLimit-Remaining": remaining.toString(), "X-RateLimit-Reset": reset.toString(), }, }); } } const { prompt, option, command } = await req.json(); const messages = match(option) .with("continue", () => [ { role: "system", content: "You are an AI writing assistant that continues existing text based on context from prior text. " + "Give more weight/priority to the later characters than the beginning ones. " + "Limit your response to no more than 200 characters, but make sure to construct complete sentences." + "Use Markdown formatting when appropriate.", }, { role: "user", content: prompt, }, ]) .with("improve", () => [ { role: "system", content: "You are an AI writing assistant that improves existing text. " + "Limit your response to no more than 200 characters, but make sure to construct complete sentences." + "Use Markdown formatting when appropriate.", }, { role: "user", content: `The existing text is: ${prompt}`, }, ]) .with("shorter", () => [ { role: "system", content: "You are an AI writing assistant that shortens existing text. " + "Use Markdown formatting when appropriate.", }, { role: "user", content: `The existing text is: ${prompt}`, }, ]) .with("longer", () => [ { role: "system", content: "You are an AI writing assistant that lengthens existing text. " + "Use Markdown formatting when appropriate.", }, { role: "user", content: `The existing text is: ${prompt}`, }, ]) .with("fix", () => [ { role: "system", content: "You are an AI writing assistant that fixes grammar and spelling errors in existing text. " + "Limit your response to no more than 200 characters, but make sure to construct complete sentences." + "Use Markdown formatting when appropriate.", }, { role: "user", content: `The existing text is: ${prompt}`, }, ]) .with("zap", () => [ { role: "system", content: "You area an AI writing assistant that generates text based on a prompt. " + "You take an input from the user and a command for manipulating the text" + "Use Markdown formatting when appropriate.", }, { role: "user", content: `For this text: ${prompt}. You have to respect the command: ${command}`, }, ]) .run(); const result = await streamText({ prompt: messages[messages.length - 1].content, maxTokens: 4096, temperature: 0.7, topP: 1, frequencyPenalty: 0, presencePenalty: 0, model: openai("gpt-4o-mini"), }); return result.toDataStreamResponse(); } ================================================ FILE: apps/web/app/api/upload/route.ts ================================================ import { put } from "@vercel/blob"; import { NextResponse } from "next/server"; export const runtime = "edge"; export async function POST(req: Request) { if (!process.env.BLOB_READ_WRITE_TOKEN) { return new Response("Missing BLOB_READ_WRITE_TOKEN. Don't forget to add that to your .env file.", { status: 401, }); } const file = req.body || ""; const filename = req.headers.get("x-vercel-filename") || "file.txt"; const contentType = req.headers.get("content-type") || "text/plain"; const fileType = `.${contentType.split("/")[1]}`; // construct final filename based on content-type if not provided const finalName = filename.includes(fileType) ? filename : `${filename}${fileType}`; const blob = await put(finalName, file, { contentType, access: "public", }); return NextResponse.json(blob); } ================================================ FILE: apps/web/app/layout.tsx ================================================ import "@/styles/globals.css"; import "@/styles/prosemirror.css"; import 'katex/dist/katex.min.css'; import type { Metadata, Viewport } from "next"; import type { ReactNode } from "react"; import Providers from "./providers"; const title = "Novel - Notion-style WYSIWYG editor with AI-powered autocompletions"; const description = "Novel is a Notion-style WYSIWYG editor with AI-powered autocompletions. Built with Tiptap, OpenAI, and Vercel AI SDK."; export const metadata: Metadata = { title, description, openGraph: { title, description, }, twitter: { title, description, card: "summary_large_image", creator: "@steventey", }, metadataBase: new URL("https://novel.sh"), }; export const viewport: Viewport = { themeColor: "#ffffff", }; export default function RootLayout({ children }: { children: ReactNode }) { return ( {children} ); } ================================================ FILE: apps/web/app/page.tsx ================================================ import TailwindAdvancedEditor from "@/components/tailwind/advanced-editor"; import { Button } from "@/components/tailwind/ui/button"; import { Dialog, DialogContent, DialogTrigger } from "@/components/tailwind/ui/dialog"; import Menu from "@/components/tailwind/ui/menu"; import { ScrollArea } from "@/components/tailwind/ui/scroll-area"; import { BookOpen, GithubIcon } from "lucide-react"; import Link from "next/link"; export default function Page() { return (
); } ================================================ FILE: apps/web/app/providers.tsx ================================================ "use client"; import { type Dispatch, type ReactNode, type SetStateAction, createContext } from "react"; import { ThemeProvider, useTheme } from "next-themes"; import { Toaster } from "sonner"; import { Analytics } from "@vercel/analytics/react"; import useLocalStorage from "@/hooks/use-local-storage"; export const AppContext = createContext<{ font: string; setFont: Dispatch>; }>({ font: "Default", setFont: () => {}, }); const ToasterProvider = () => { const { theme } = useTheme() as { theme: "light" | "dark" | "system"; }; return ; }; export default function Providers({ children }: { children: ReactNode }) { const [font, setFont] = useLocalStorage("novel__font", "Default"); return ( {children} ); } ================================================ FILE: apps/web/biome.json ================================================ { "extends": ["../../biome.json"] } ================================================ FILE: apps/web/components/tailwind/advanced-editor.tsx ================================================ "use client"; import { defaultEditorContent } from "@/lib/content"; import { EditorCommand, EditorCommandEmpty, EditorCommandItem, EditorCommandList, EditorContent, type EditorInstance, EditorRoot, ImageResizer, type JSONContent, handleCommandNavigation, handleImageDrop, handleImagePaste, } from "novel"; import { useEffect, useState } from "react"; import { useDebouncedCallback } from "use-debounce"; import { defaultExtensions } from "./extensions"; import { ColorSelector } from "./selectors/color-selector"; import { LinkSelector } from "./selectors/link-selector"; import { MathSelector } from "./selectors/math-selector"; import { NodeSelector } from "./selectors/node-selector"; import { Separator } from "./ui/separator"; import GenerativeMenuSwitch from "./generative/generative-menu-switch"; import { uploadFn } from "./image-upload"; import { TextButtons } from "./selectors/text-buttons"; import { slashCommand, suggestionItems } from "./slash-command"; const hljs = require("highlight.js"); const extensions = [...defaultExtensions, slashCommand]; const TailwindAdvancedEditor = () => { const [initialContent, setInitialContent] = useState(null); const [saveStatus, setSaveStatus] = useState("Saved"); const [charsCount, setCharsCount] = useState(); const [openNode, setOpenNode] = useState(false); const [openColor, setOpenColor] = useState(false); const [openLink, setOpenLink] = useState(false); const [openAI, setOpenAI] = useState(false); //Apply Codeblock Highlighting on the HTML from editor.getHTML() const highlightCodeblocks = (content: string) => { const doc = new DOMParser().parseFromString(content, "text/html"); doc.querySelectorAll("pre code").forEach((el) => { // @ts-ignore // https://highlightjs.readthedocs.io/en/latest/api.html?highlight=highlightElement#highlightelement hljs.highlightElement(el); }); return new XMLSerializer().serializeToString(doc); }; const debouncedUpdates = useDebouncedCallback(async (editor: EditorInstance) => { const json = editor.getJSON(); setCharsCount(editor.storage.characterCount.words()); window.localStorage.setItem("html-content", highlightCodeblocks(editor.getHTML())); window.localStorage.setItem("novel-content", JSON.stringify(json)); window.localStorage.setItem("markdown", editor.storage.markdown.getMarkdown()); setSaveStatus("Saved"); }, 500); useEffect(() => { const content = window.localStorage.getItem("novel-content"); if (content) setInitialContent(JSON.parse(content)); else setInitialContent(defaultEditorContent); }, []); if (!initialContent) return null; return (
{saveStatus}
{charsCount} Words
handleCommandNavigation(event), }, handlePaste: (view, event) => handleImagePaste(view, event, uploadFn), handleDrop: (view, event, _slice, moved) => handleImageDrop(view, event, moved, uploadFn), attributes: { class: "prose prose-lg dark:prose-invert prose-headings:font-title font-default focus:outline-none max-w-full", }, }} onUpdate={({ editor }) => { debouncedUpdates(editor); setSaveStatus("Unsaved"); }} slotAfter={} > No results {suggestionItems.map((item) => ( item.command(val)} 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" key={item.title} >
{item.icon}

{item.title}

{item.description}

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

Font

{fonts.map(({ font, icon }) => ( ))}
*/}

Appearance

{appearances.map(({ theme, icon }) => ( ))}
); } ================================================ FILE: apps/web/components/tailwind/ui/popover.tsx ================================================ "use client"; import * as React from "react"; import * as PopoverPrimitive from "@radix-ui/react-popover"; import { cn } from "@/lib/utils"; const Popover = PopoverPrimitive.Root; const PopoverTrigger = PopoverPrimitive.Trigger; const PopoverContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( )); PopoverContent.displayName = PopoverPrimitive.Content.displayName; export { Popover, PopoverTrigger, PopoverContent }; ================================================ FILE: apps/web/components/tailwind/ui/scroll-area.tsx ================================================ "use client"; import * as React from "react"; import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; import { cn } from "@/lib/utils"; const ScrollArea = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( {children} )); ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; const ScrollBar = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, orientation = "vertical", ...props }, ref) => ( )); ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; export { ScrollArea, ScrollBar }; ================================================ FILE: apps/web/components/tailwind/ui/separator.tsx ================================================ "use client"; import * as React from "react"; import * as SeparatorPrimitive from "@radix-ui/react-separator"; import { cn } from "@/lib/utils"; const Separator = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => ( )); Separator.displayName = SeparatorPrimitive.Root.displayName; export { Separator }; ================================================ FILE: apps/web/components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "default", "rsc": true, "tsx": true, "tailwind": { "config": "tailwind.config.ts", "css": "styles/globals.css", "baseColor": "slate", "cssVariables": true, "prefix": "" }, "aliases": { "components": "@/components/tailwind", "utils": "@/lib/utils" } } ================================================ FILE: apps/web/hooks/use-local-storage.ts ================================================ import { useEffect, useState } from "react"; const useLocalStorage = ( key: string, initialValue: T, // eslint-disable-next-line no-unused-vars ): [T, (value: T) => void] => { const [storedValue, setStoredValue] = useState(initialValue); useEffect(() => { // Retrieve from localStorage const item = window.localStorage.getItem(key); if (item) { setStoredValue(JSON.parse(item)); } }, [key]); const setValue = (value: T) => { // Save state setStoredValue(value); // Save to localStorage window.localStorage.setItem(key, JSON.stringify(value)); }; return [storedValue, setValue]; }; export default useLocalStorage; ================================================ FILE: apps/web/lib/content.ts ================================================ export const defaultEditorContent = { type: "doc", content: [ { type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Introducing Novel" }], }, { type: "paragraph", content: [ { type: "text", marks: [ { type: "link", attrs: { href: "https://github.com/steven-tey/novel", target: "_blank", }, }, ], text: "Novel", }, { type: "text", text: " is a Notion-style WYSIWYG editor with AI-powered autocompletion. Built with ", }, { type: "text", marks: [ { type: "link", attrs: { href: "https://tiptap.dev/", target: "_blank", }, }, ], text: "Tiptap", }, { type: "text", text: " + " }, { type: "text", marks: [ { type: "link", attrs: { href: "https://sdk.vercel.ai/docs", target: "_blank", }, }, ], text: "Vercel AI SDK", }, { type: "text", text: "." }, ], }, { type: "heading", attrs: { level: 3 }, content: [{ type: "text", text: "Installation" }], }, { type: "codeBlock", attrs: { language: null }, content: [{ type: "text", text: "npm i novel" }], }, { type: "heading", attrs: { level: 3 }, content: [{ type: "text", text: "Usage" }], }, { type: "codeBlock", attrs: { language: null }, content: [ { type: "text", text: 'import { Editor } from "novel";\n\nexport default function App() {\n return (\n \n )\n}', }, ], }, { type: "heading", attrs: { level: 3 }, content: [{ type: "text", text: "Features" }], }, { type: "orderedList", attrs: { tight: true, start: 1 }, content: [ { type: "listItem", content: [ { type: "paragraph", content: [{ type: "text", text: "Slash menu & bubble menu" }], }, ], }, { type: "listItem", content: [ { type: "paragraph", content: [ { type: "text", text: "AI autocomplete (type " }, { type: "text", marks: [{ type: "code" }], text: "++" }, { type: "text", text: " to activate, or select from slash menu)", }, ], }, ], }, { type: "listItem", content: [ { type: "paragraph", content: [ { type: "text", text: "Image uploads (drag & drop / copy & paste, or select from slash menu) ", }, ], }, ], }, { type: "listItem", content: [ { type: "paragraph", content: [ { type: "text", text: "Add tweets from the command slash menu:", }, ], }, { type: "twitter", attrs: { src: "https://x.com/elonmusk/status/1800759252224729577", }, }, ], }, { type: "listItem", content: [ { type: "paragraph", content: [ { type: "text", text: "Mathematical symbols with LaTeX expression:", }, ], }, { type: "orderedList", attrs: { tight: true, start: 1, }, content: [ { type: "listItem", content: [ { type: "paragraph", content: [ { type: "math", attrs: { latex: "E = mc^2", }, }, ], }, ], }, { type: "listItem", content: [ { type: "paragraph", content: [ { type: "math", attrs: { latex: "a^2 = \\sqrt{b^2 + c^2}", }, }, ], }, ], }, { type: "listItem", content: [ { type: "paragraph", content: [ { type: "math", attrs: { latex: "\\hat{f} (\\xi)=\\int_{-\\infty}^{\\infty}f(x)e^{-2\\pi ix\\xi}dx", }, }, ], }, ], }, { type: "listItem", content: [ { type: "paragraph", content: [ { type: "math", attrs: { latex: "A=\\begin{bmatrix}a&b\\\\c&d \\end{bmatrix}", }, }, ], }, ], }, { type: "listItem", content: [ { type: "paragraph", content: [ { type: "math", attrs: { latex: "\\sum_{i=0}^n x_i", }, }, ], }, ], }, ], }, ], }, ], }, { type: "image", attrs: { src: "https://public.blob.vercel-storage.com/pJrjXbdONOnAeZAZ/banner-2wQk82qTwyVgvlhTW21GIkWgqPGD2C.png", alt: "banner.png", title: "banner.png", width: null, height: null, }, }, { type: "horizontalRule" }, { type: "heading", attrs: { level: 3 }, content: [{ type: "text", text: "Learn more" }], }, { type: "taskList", content: [ { type: "taskItem", attrs: { checked: false }, content: [ { type: "paragraph", content: [ { type: "text", text: "Star us on " }, { type: "text", marks: [ { type: "link", attrs: { href: "https://github.com/steven-tey/novel", target: "_blank", }, }, ], text: "GitHub", }, ], }, ], }, { type: "taskItem", attrs: { checked: false }, content: [ { type: "paragraph", content: [ { type: "text", text: "Install the " }, { type: "text", marks: [ { type: "link", attrs: { href: "https://www.npmjs.com/package/novel", target: "_blank", }, }, ], text: "NPM package", }, ], }, ], }, { type: "taskItem", attrs: { checked: false }, content: [ { type: "paragraph", content: [ { type: "text", marks: [ { type: "link", attrs: { href: "https://vercel.com/templates/next.js/novel", target: "_blank", }, }, ], text: "Deploy your own", }, { type: "text", text: " to Vercel" }, ], }, ], }, ], }, ], }; ================================================ FILE: apps/web/lib/utils.ts ================================================ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } ================================================ FILE: apps/web/next.config.js ================================================ /** @type {import('next').NextConfig} */ const nextConfig = { redirects: async () => { return [ { source: "/github", destination: "https://github.com/steven-tey/novel", permanent: true, }, { source: "/sdk", destination: "https://www.npmjs.com/package/novel", permanent: true, }, { source: "/npm", destination: "https://www.npmjs.com/package/novel", permanent: true, }, { source: "/svelte", destination: "https://github.com/tglide/novel-svelte", permanent: false, }, { source: "/vue", destination: "https://github.com/naveennaidu/novel-vue", permanent: false, }, { source: "/vscode", destination: "https://marketplace.visualstudio.com/items?itemName=bennykok.novel-vscode", permanent: false, }, { source: "/feedback", destination: "https://github.com/steven-tey/novel/issues", permanent: true, }, { source: "/deploy", destination: "https://vercel.com/templates/next.js/novel", permanent: true, }, ]; }, productionBrowserSourceMaps: true, }; module.exports = nextConfig; ================================================ FILE: apps/web/package.json ================================================ { "name": "novel-next-app", "version": "0.1.0", "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "biome lint .", "format": "biome format . ", "typecheck": "tsc --noEmit" }, "dependencies": { "@ai-sdk/openai": "^1.1.0", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", "@tailwindcss/typography": "^0.5.10", "@upstash/ratelimit": "^1.0.1", "@vercel/analytics": "^1.2.2", "@vercel/blob": "^0.22.1", "@vercel/kv": "^1.0.1", "ai": "^3.0.12", "autoprefixer": "^10.4.17", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "cmdk": "^1.0.4", "eventsource-parser": "^1.1.2", "highlight.js": "^11.9.0", "lowlight": "^3.1.0", "lucide-react": "^0.358.0", "next": "15.1.4", "next-themes": "^0.2.1", "novel": "workspace:^", "openai": "^4.28.4", "react": "18.2.0", "react-dom": "18.2.0", "react-markdown": "^9.0.1", "sonner": "^1.4.3", "tailwind-merge": "^2.2.1", "tailwindcss-animate": "^1.0.7", "tippy.js": "^6.3.7", "ts-pattern": "^5.0.8", "typescript": "^5.4.2", "use-debounce": "^10.0.0" }, "devDependencies": { "@biomejs/biome": "^1.7.2", "@types/node": "20.11.24", "@types/react": "^18.2.61", "@types/react-dom": "18.2.19", "tailwindcss": "^3.4.1", "tsconfig": "workspace:*" } } ================================================ FILE: apps/web/postcss.config.js ================================================ module.exports = { plugins: { "postcss-import": {}, "tailwindcss/nesting": {}, tailwindcss: {}, autoprefixer: {}, }, }; ================================================ FILE: apps/web/styles/fonts.ts ================================================ import localFont from "next/font/local"; import { Crimson_Text, Inconsolata, Inter } from "next/font/google"; export const cal = localFont({ src: "./CalSans-SemiBold.otf", variable: "--font-title", }); export const crimsonBold = Crimson_Text({ weight: "700", variable: "--font-title", subsets: ["latin"], }); export const inter = Inter({ variable: "--font-default", subsets: ["latin"], }); export const inconsolataBold = Inconsolata({ weight: "700", variable: "--font-title", subsets: ["latin"], }); export const crimson = Crimson_Text({ weight: "400", variable: "--font-default", subsets: ["latin"], }); export const inconsolata = Inconsolata({ variable: "--font-default", subsets: ["latin"], }); export const titleFontMapper = { Default: cal.variable, Serif: crimsonBold.variable, Mono: inconsolataBold.variable, }; export const defaultFontMapper = { Default: inter.variable, Serif: crimson.variable, Mono: inconsolata.variable, }; ================================================ FILE: apps/web/styles/globals.css ================================================ @tailwind base; @tailwind components; @tailwind utilities; @layer base { :root { --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; --card: 0 0% 100%; --card-foreground: 222.2 84% 4.9%; --popover: 0 0% 100%; --popover-foreground: 222.2 84% 4.9%; --primary: 222.2 47.4% 11.2%; --primary-foreground: 210 40% 98%; --secondary: 210 40% 96.1%; --secondary-foreground: 222.2 47.4% 11.2%; --muted: 210 40% 96.1%; --muted-foreground: 215.4 16.3% 46.9%; --accent: 210 40% 96.1%; --accent-foreground: 222.2 47.4% 11.2%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%; --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --ring: 222.2 84% 4.9%; --radius: 0.5rem; --novel-highlight-default: #ffffff; --novel-highlight-purple: #f6f3f8; --novel-highlight-red: #fdebeb; --novel-highlight-yellow: #fbf4a2; --novel-highlight-blue: #c1ecf9; --novel-highlight-green: #acf79f; --novel-highlight-orange: #faebdd; --novel-highlight-pink: #faf1f5; --novel-highlight-gray: #f1f1ef; } .dark { --background: 222.2 84% 4.9%; --foreground: 210 40% 98%; --card: 222.2 84% 4.9%; --card-foreground: 210 40% 98%; --popover: 222.2 84% 4.9%; --popover-foreground: 210 40% 98%; --primary: 210 40% 98%; --primary-foreground: 222.2 47.4% 11.2%; --secondary: 217.2 32.6% 17.5%; --secondary-foreground: 210 40% 98%; --muted: 217.2 32.6% 17.5%; --muted-foreground: 215 20.2% 65.1%; --accent: 217.2 32.6% 17.5%; --accent-foreground: 210 40% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 210 40% 98%; --border: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%; --ring: 212.7 26.8% 83.9%; --novel-highlight-default: #000000; --novel-highlight-purple: #3f2c4b; --novel-highlight-red: #5c1a1a; --novel-highlight-yellow: #5c4b1a; --novel-highlight-blue: #1a3d5c; --novel-highlight-green: #1a5c20; --novel-highlight-orange: #5c3a1a; --novel-highlight-pink: #5c1a3a; --novel-highlight-gray: #3a3a3a; } } @layer base { * { @apply border-border; } body { @apply bg-background text-foreground; } } pre { background: #0d0d0d; border-radius: 0.5rem; color: #fff; font-family: "JetBrainsMono", monospace; padding: 0.75rem 1rem; code { background: none; color: inherit; font-size: 0.8rem; padding: 0; } .hljs-comment, .hljs-quote { color: #616161; } .hljs-variable, .hljs-template-variable, .hljs-attribute, .hljs-tag, .hljs-name, .hljs-regexp, .hljs-link, .hljs-name, .hljs-selector-id, .hljs-selector-class { color: #f98181; } .hljs-number, .hljs-meta, .hljs-built_in, .hljs-builtin-name, .hljs-literal, .hljs-type, .hljs-params { color: #fbbc88; } .hljs-string, .hljs-symbol, .hljs-bullet { color: #b9f18d; } .hljs-title, .hljs-section { color: #faf594; } .hljs-keyword, .hljs-selector-tag { color: #70cff8; } .hljs-emphasis { font-style: italic; } .hljs-strong { font-weight: 700; } } ================================================ FILE: apps/web/styles/prosemirror.css ================================================ .ProseMirror { @apply p-12 px-8 sm:px-12; } .ProseMirror .is-editor-empty:first-child::before { content: attr(data-placeholder); float: left; color: hsl(var(--muted-foreground)); pointer-events: none; height: 0; } .ProseMirror .is-empty::before { content: attr(data-placeholder); float: left; color: hsl(var(--muted-foreground)); pointer-events: none; height: 0; } /* Custom image styles */ .ProseMirror img { transition: filter 0.1s ease-in-out; &:hover { cursor: pointer; filter: brightness(90%); } &.ProseMirror-selectednode { outline: 3px solid #5abbf7; filter: brightness(90%); } } .img-placeholder { position: relative; &:before { content: ""; box-sizing: border-box; position: absolute; top: 50%; left: 50%; width: 36px; height: 36px; border-radius: 50%; border: 3px solid var(--novel-stone-200); border-top-color: var(--novel-stone-800); animation: spinning 0.6s linear infinite; } } @keyframes spinning { to { transform: rotate(360deg); } } /* Custom TODO list checkboxes – shoutout to this awesome tutorial: https://moderncss.dev/pure-css-custom-checkbox-style/ */ ul[data-type="taskList"] li > label { margin-right: 0.2rem; user-select: none; } @media screen and (max-width: 768px) { ul[data-type="taskList"] li > label { margin-right: 0.5rem; } } ul[data-type="taskList"] li > label input[type="checkbox"] { -webkit-appearance: none; appearance: none; background-color: hsl(var(--background)); margin: 0; cursor: pointer; width: 1.2em; height: 1.2em; position: relative; top: 5px; border: 2px solid hsl(var(--border)); margin-right: 0.3rem; display: grid; place-content: center; &:hover { background-color: hsl(var(--accent)); } &:active { background-color: hsl(var(--accent)); } &::before { content: ""; width: 0.65em; height: 0.65em; transform: scale(0); transition: 120ms transform ease-in-out; box-shadow: inset 1em 1em; transform-origin: center; clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); } &:checked::before { transform: scale(1); } } ul[data-type="taskList"] li[data-checked="true"] > div > p { color: var(--muted-foreground); text-decoration: line-through; text-decoration-thickness: 2px; } /* Overwrite tippy-box original max-width */ .tippy-box { max-width: 400px !important; } .ProseMirror:not(.dragging) .ProseMirror-selectednode { outline: none !important; background-color: var(--novel-highlight-blue); transition: background-color 0.2s; box-shadow: none; } .drag-handle { position: fixed; opacity: 1; transition: opacity ease-in 0.2s; border-radius: 0.25rem; 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"); background-size: calc(0.5em + 0.375rem) calc(0.5em + 0.375rem); background-repeat: no-repeat; background-position: center; width: 1.2rem; height: 1.5rem; z-index: 50; cursor: grab; &:hover { background-color: var(--novel-stone-100); transition: background-color 0.2s; } &:active { background-color: var(--novel-stone-200); transition: background-color 0.2s; cursor: grabbing; } &.hide { opacity: 0; pointer-events: none; } @media screen and (max-width: 600px) { display: none; pointer-events: none; } } .dark .drag-handle { 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"); } /* Custom Youtube Video CSS */ iframe { border: 8px solid #ffd00027; border-radius: 4px; min-width: 200px; min-height: 200px; display: block; outline: 0px solid transparent; } div[data-youtube-video] > iframe { cursor: move; aspect-ratio: 16 / 9; width: 100%; } .ProseMirror-selectednode iframe { transition: outline 0.15s; outline: 6px solid #fbbf24; } @media only screen and (max-width: 480px) { div[data-youtube-video] > iframe { max-height: 50px; } } @media only screen and (max-width: 720px) { div[data-youtube-video] > iframe { max-height: 100px; } } /* CSS for bold coloring and highlighting issue*/ span[style] > strong { color: inherit; } mark[style] > strong { color: inherit; } ================================================ FILE: apps/web/tailwind.config.ts ================================================ import type { Config } from "tailwindcss"; const config = { darkMode: ["class"], content: [ "./pages/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./app/**/*.{ts,tsx}", "./src/**/*.{ts,tsx}", "./lib/**/*.{ts,tsx}", ], prefix: "", theme: { container: { center: true, padding: "2rem", screens: { "2xl": "1400px", }, }, extend: { colors: { border: "hsl(var(--border))", input: "hsl(var(--input))", ring: "hsl(var(--ring))", background: "hsl(var(--background))", foreground: "hsl(var(--foreground))", primary: { DEFAULT: "hsl(var(--primary))", foreground: "hsl(var(--primary-foreground))", }, secondary: { DEFAULT: "hsl(var(--secondary))", foreground: "hsl(var(--secondary-foreground))", }, destructive: { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))", }, muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))", }, accent: { DEFAULT: "hsl(var(--accent))", foreground: "hsl(var(--accent-foreground))", }, popover: { DEFAULT: "hsl(var(--popover))", foreground: "hsl(var(--popover-foreground))", }, card: { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))", }, }, borderRadius: { lg: "var(--radius)", md: "calc(var(--radius) - 2px)", sm: "calc(var(--radius) - 4px)", }, keyframes: { "accordion-down": { from: { height: "0" }, to: { height: "var(--radix-accordion-content-height)" }, }, "accordion-up": { from: { height: "var(--radix-accordion-content-height)" }, to: { height: "0" }, }, }, animation: { "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out", }, }, }, plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")], } satisfies Config; export default config; ================================================ FILE: apps/web/tsconfig.json ================================================ { "extends": "tsconfig/next.json", "compilerOptions": { "plugins": [ { "name": "next" } ], "paths": { "@/*": ["./*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"] } ================================================ FILE: apps/web/vercel.json ================================================ { "rewrites": [ { "source": "/docs", "destination": "https://novel.mintlify.dev/docs" }, { "source": "/docs/:match*", "destination": "https://novel.mintlify.dev/docs/:match*" } ] } ================================================ FILE: biome.json ================================================ { "$schema": "https://biomejs.dev/schemas/1.6.4/schema.json", "files": { "ignoreUnknown": true, "ignore": [ "node_modules/*", "*.config.*", "*.json", "tsconfig.json", ".turbo", "**/dist", "**/out", ".next" ] }, "organizeImports": { "enabled": true }, "linter": { "enabled": true, "rules": { "recommended": true, "complexity": { "noForEach": "off", "noUselessFragments": "off" }, "correctness": { "useExhaustiveDependencies": "off", "noUnusedImports": "warn", "noUnusedVariables": "warn" }, "style": { "noParameterAssign": "off" } } }, "formatter": { "enabled": true, "formatWithErrors": false, "indentStyle": "space", "lineEnding": "lf", "lineWidth": 120 } } ================================================ FILE: package.json ================================================ { "name": "novel", "private": true, "scripts": { "changeset": "changeset", "publish:packages": "changeset publish", "version:packages": "turbo build && changeset version", "build": "turbo build", "dev": "turbo dev", "format": "turbo format --continue --", "format:fix": "turbo format --continue -- --write", "lint": "turbo lint --continue --", "lint:fix": "turbo lint --continue -- --apply", "clean": "turbo clean", "release": "turbo run release", "prepare": "husky install", "typecheck": "turbo typecheck" }, "dependencies": { "@changesets/changelog-github": "^0.5.0", "@changesets/cli": "^2.27.11", "turbo": "^2.3.3" }, "packageManager": "pnpm@9.5.0", "devDependencies": { "@biomejs/biome": "^1.9.4", "@commitlint/cli": "^19.6.1", "@commitlint/config-conventional": "^19.6.0", "husky": "^9.1.7", "postcss": "^8.5.1" }, "commitlint": { "extends": [ "@commitlint/config-conventional" ], "rules": { "type-enum": [ 2, "always", [ "build", "chore", "ci", "clean", "doc", "feat", "fix", "perf", "ref", "revert", "style", "test" ] ], "subject-case": [ 0, "always", "sentence-case" ], "body-leading-blank": [ 2, "always", true ], "body-max-line-length": [ 0, "always", 100 ] } } } ================================================ FILE: packages/headless/CHANGELOG.md ================================================ # [0.2.0](https://github.com/steven-tey/novel/compare/v0.1.0...v0.2.0) (2025-01-17) ## 1.0.0 ### Major Changes - cleanup novel ### Bug Fixes - add correct config ([7c2a9a1](https://github.com/steven-tey/novel/commit/7c2a9a1eb79c774e5b11ca1f137f7f28ee4aaadf)) - add correct workflow ([db2781c](https://github.com/steven-tey/novel/commit/db2781c48e1e25517d8c473209b09a06ea2edda4)) - add crazy spinner ([256ab4a](https://github.com/steven-tey/novel/commit/256ab4a03b168ee170e90b91a90ad7b8df0e3ec6)) - add default prose ([cb93667](https://github.com/steven-tey/novel/commit/cb9366704180f9c7b5ccd793806af35d6c8f7d97)) - add docs button ([1cc5140](https://github.com/steven-tey/novel/commit/1cc514089c8ba0b1e19e26e0d72b45c4f68ea360)) - add missing dependecy ([75da619](https://github.com/steven-tey/novel/commit/75da61969b43a6f1b567c624f1a68e119578a589)) - add shorter longer options ([abdd1b7](https://github.com/steven-tey/novel/commit/abdd1b795acf1fe7211e7983171c5c73670b473a)) - build fix & prettier top level ([a8d30fc](https://github.com/steven-tey/novel/commit/a8d30fc28550fa3d1238bca0e4ec0e7a83d5ebdd)) - bump version ([74ebbc0](https://github.com/steven-tey/novel/commit/74ebbc0852deeca76668b3319e490582756c97ed)) - bump version ([2abe146](https://github.com/steven-tey/novel/commit/2abe1460207d78651593cd75990ea39195b8d5b8)) - bump version ([41d7e6a](https://github.com/steven-tey/novel/commit/41d7e6afdf787d82095bef6b819ed7572e3559f8)) - bump version ([02409d1](https://github.com/steven-tey/novel/commit/02409d14918166be52f0976a4a7f5b2df8cd4e81)) - bump version ([4294547](https://github.com/steven-tey/novel/commit/4294547e97bbd5788a4db58d8c75540cb19fc155)) - bump version ([75ed43a](https://github.com/steven-tey/novel/commit/75ed43afe643671b7249874e29e4e62053c98f40)) - bump version ([4c4b28f](https://github.com/steven-tey/novel/commit/4c4b28f981073b79a3068a8d5d1979b55cfdba20)) - cannot set text color when the text is bold ([6db8197](https://github.com/steven-tey/novel/commit/6db81970ebc2773ea723dfe60b644766b0a238dd)) - checkbox fix ([4d49c2f](https://github.com/steven-tey/novel/commit/4d49c2f92ded59c73e5e79ccbc95eed0e75f361a)) - chore bump version ([68243f6](https://github.com/steven-tey/novel/commit/68243f69613a94e414fb401992a613228f40ac98)) - chore bump version ([7051d54](https://github.com/steven-tey/novel/commit/7051d542a78320565d8ce2fe9086967603934ace)) - codeblock-lowlight doesn't render in html output ([de5c2e5](https://github.com/steven-tey/novel/commit/de5c2e55e1b7cb553ee55437d363fc0a041552c9)) - colection to collection ([50248de](https://github.com/steven-tey/novel/commit/50248dea0bb3463850f7cb1332fbc03377c17594)) - default value and css styles ([ddf0e01](https://github.com/steven-tey/novel/commit/ddf0e0145d2e41dc2ab694bf29fcc9a93d57c04e)) - **docs:** tailwind extensions guide ([7274419](https://github.com/steven-tey/novel/commit/7274419fa74a21b27f8de3db93249e90608ca6ff)) - **docs:** tailwind extensions guide ([a1457c4](https://github.com/steven-tey/novel/commit/a1457c4dfc2f869bb59e8950c49e05a50938b652)) - dont trigger slash-command on codeBlock nodes ([8a3570b](https://github.com/steven-tey/novel/commit/8a3570bd72f32076f9edb42b0492e5eea8d5a014)) - error deploy ([ca06a20](https://github.com/steven-tey/novel/commit/ca06a205fa9ffdc9a8f5b96051cc80244239989d)) - expose editor command list ([8456652](https://github.com/steven-tey/novel/commit/84566528fc3a4c3420c94f68a54a600810e9942c)) - expose utils ([33252ac](https://github.com/steven-tey/novel/commit/33252ac5148437cd4be7be736b7b8f872b07888d)) - format lint ([d4484f9](https://github.com/steven-tey/novel/commit/d4484f96b764f17ac9bcb152db7caa5279ca7666)) - image is deleted if an error occurs ([13e74b3](https://github.com/steven-tey/novel/commit/13e74b3a4b9b77356c895892f0a142d3a872403e)) - keep drag handle ([8363ea6](https://github.com/steven-tey/novel/commit/8363ea60c6bc7bd64c5e8409b38a8696d1d7240f)) - not show bubble menu if editor cannot be editable ([2aeee1b](https://github.com/steven-tey/novel/commit/2aeee1b1f402cd2006a9f86b0450efb8db003e73)) - pass ref to div ([a3cd338](https://github.com/steven-tey/novel/commit/a3cd33888ab47a86a80f000a35b8abd6c663e431)) - remove AI autocomplete default placeholder ([5ca260e](https://github.com/steven-tey/novel/commit/5ca260e4496ec9e4da114cb41ee874090a519c13)) - remove auto joiner ([dbef03c](https://github.com/steven-tey/novel/commit/dbef03c5cafb691c3dc4f8c14e6944707ca70a98)) - remove default extensions and make them standalone exports ([54bfd40](https://github.com/steven-tey/novel/commit/54bfd404b013a094449a2ac16ec47a561ddd15a5)) - remove drag-handle on drop ([1ec5518](https://github.com/steven-tey/novel/commit/1ec551819e2490afddd1e042e0eb70d626c3325d)) - remove katex styling import inside mathematics extension ([91264c7](https://github.com/steven-tey/novel/commit/91264c7c764f8a1b0d3859c3179fc8ccf18f330f)) - rename to workflows ([16d3ff5](https://github.com/steven-tey/novel/commit/16d3ff56a6b1cb291b79c72dcf71ed5735645dcc)) - rename updated image type ([d1b21d6](https://github.com/steven-tey/novel/commit/d1b21d695ba105e3e7f26d607ee8969b92289ff5)) - safari related fix ([40a6fd8](https://github.com/steven-tey/novel/commit/40a6fd8ef3d881a89792e2c078c4f56bde4327ec)) - The tailwind example link on setup page redirects to correct file ([55e4e69](https://github.com/steven-tey/novel/commit/55e4e69da6059023716e08bbb40c87cf829c9588)) - update docs & rename type to EditorInstance ([ff9cf90](https://github.com/steven-tey/novel/commit/ff9cf902581cc2a65167f2b5493c9c183a489817)) - update packge docs ([40b860f](https://github.com/steven-tey/novel/commit/40b860f654483934846fe66991c13906400c0fb1)) - update tiptap-markdown ([d6358fe](https://github.com/steven-tey/novel/commit/d6358fe2c036e2d4c8101abb0912c8faabc9cf60)) - update types ([8d168f5](https://github.com/steven-tey/novel/commit/8d168f58ca8e6cc9a859f232a570a2ded6532367)) - use per-editor instance of tunnel to render slash command popover ([d05c03f](https://github.com/steven-tey/novel/commit/d05c03ff5f668d8b65a1729a9adc2d79315f9591)) - use verbatim import ([1c4df17](https://github.com/steven-tey/novel/commit/1c4df17f252773ca472150541468a01c18d37588)) ### Features - add ai features example ([2a5e18c](https://github.com/steven-tey/novel/commit/2a5e18c8950f2e26659775d812b1be1d56baf169)) - add custom highlight extension ([45efd37](https://github.com/steven-tey/novel/commit/45efd37d157e5f0654fb8ec83e5175df7b2aa918)) - add custom upload config ([a202e6e](https://github.com/steven-tey/novel/commit/a202e6eb14488fe640e082d9fa665ce32ff02f65)) - add dialog usage ([9152e46](https://github.com/steven-tey/novel/commit/9152e461a5ba8fb6480d423b4688a15407365c47)) - add docs step to include editor props ([17dcc6d](https://github.com/steven-tey/novel/commit/17dcc6d94a9d213f86a704c043985b97138912c1)) - add issue template ([2241fd1](https://github.com/steven-tey/novel/commit/2241fd1c5275456f8cd81cffb19dbe9055444bab)) - add mathematics extension ([15b4428](https://github.com/steven-tey/novel/commit/15b44284d60a7ee88da3e1f1ee3462acdc1f8af8)) - add twitter extension ([e019f34](https://github.com/steven-tey/novel/commit/e019f34575b0a9d8a1e1fbc04c72c100780ed035)) - add utils functions for text generation ([7e99b72](https://github.com/steven-tey/novel/commit/7e99b722e393cde51bd206b0d012c130837c7137)) - ai prev markdown ([122b3ee](https://github.com/steven-tey/novel/commit/122b3eed4e748e0824b6a7d670bf26c93bdada5a)) - clear nodes on node selector ([596d811](https://github.com/steven-tey/novel/commit/596d81176030b29dfa1b41ee99797e855e9cafbe)) - configure changeset for release ([c09dd55](https://github.com/steven-tey/novel/commit/c09dd55f0cc271b8d272a03a14a8b6108f611ee5)) - fix biome linting ([081ab3b](https://github.com/steven-tey/novel/commit/081ab3bd6367d6e2e5660da1f2615ce322def85c)) - forward ref components ([957e5dc](https://github.com/steven-tey/novel/commit/957e5dc279c804bab4c4af8a94f9275967fbbc65)) - remove old docs & example add typecheck ([e0b2c99](https://github.com/steven-tey/novel/commit/e0b2c99b913fb283d5b55d94a0837b986936e160)) - support for custom OpenAI base url ([7ac5895](https://github.com/steven-tey/novel/commit/7ac5895b7aece309a1e671bf5fa4d5042db296ea)) - update docs ([9534c6e](https://github.com/steven-tey/novel/commit/9534c6ed78fc5850e46673499117fc144c770058)) - update docs with demo code link ([4569347](https://github.com/steven-tey/novel/commit/4569347e8306517747aa0b5be40c399a286b1b9a)) - use biome for linting & formatting ([e2601a0](https://github.com/steven-tey/novel/commit/e2601a059332e7db580d517f3081d7db555a1fb3)) - use semantic release library ([4854d8a](https://github.com/steven-tey/novel/commit/4854d8a4a1d315dfbd3d96ca9e9a91e4f08afbfe)) # [0.2.0](https://github.com/steven-tey/novel/compare/v0.1.0...v0.2.0) (2025-01-17) ### Bug Fixes - add correct config ([7c2a9a1](https://github.com/steven-tey/novel/commit/7c2a9a1eb79c774e5b11ca1f137f7f28ee4aaadf)) - add correct workflow ([db2781c](https://github.com/steven-tey/novel/commit/db2781c48e1e25517d8c473209b09a06ea2edda4)) - add crazy spinner ([256ab4a](https://github.com/steven-tey/novel/commit/256ab4a03b168ee170e90b91a90ad7b8df0e3ec6)) - add default prose ([cb93667](https://github.com/steven-tey/novel/commit/cb9366704180f9c7b5ccd793806af35d6c8f7d97)) - add docs button ([1cc5140](https://github.com/steven-tey/novel/commit/1cc514089c8ba0b1e19e26e0d72b45c4f68ea360)) - add missing dependecy ([75da619](https://github.com/steven-tey/novel/commit/75da61969b43a6f1b567c624f1a68e119578a589)) - add shorter longer options ([abdd1b7](https://github.com/steven-tey/novel/commit/abdd1b795acf1fe7211e7983171c5c73670b473a)) - build fix & prettier top level ([a8d30fc](https://github.com/steven-tey/novel/commit/a8d30fc28550fa3d1238bca0e4ec0e7a83d5ebdd)) - bump version ([74ebbc0](https://github.com/steven-tey/novel/commit/74ebbc0852deeca76668b3319e490582756c97ed)) - bump version ([2abe146](https://github.com/steven-tey/novel/commit/2abe1460207d78651593cd75990ea39195b8d5b8)) - bump version ([41d7e6a](https://github.com/steven-tey/novel/commit/41d7e6afdf787d82095bef6b819ed7572e3559f8)) - bump version ([02409d1](https://github.com/steven-tey/novel/commit/02409d14918166be52f0976a4a7f5b2df8cd4e81)) - bump version ([4294547](https://github.com/steven-tey/novel/commit/4294547e97bbd5788a4db58d8c75540cb19fc155)) - bump version ([75ed43a](https://github.com/steven-tey/novel/commit/75ed43afe643671b7249874e29e4e62053c98f40)) - bump version ([4c4b28f](https://github.com/steven-tey/novel/commit/4c4b28f981073b79a3068a8d5d1979b55cfdba20)) - cannot set text color when the text is bold ([6db8197](https://github.com/steven-tey/novel/commit/6db81970ebc2773ea723dfe60b644766b0a238dd)) - checkbox fix ([4d49c2f](https://github.com/steven-tey/novel/commit/4d49c2f92ded59c73e5e79ccbc95eed0e75f361a)) - chore bump version ([68243f6](https://github.com/steven-tey/novel/commit/68243f69613a94e414fb401992a613228f40ac98)) - chore bump version ([7051d54](https://github.com/steven-tey/novel/commit/7051d542a78320565d8ce2fe9086967603934ace)) - codeblock-lowlight doesn't render in html output ([de5c2e5](https://github.com/steven-tey/novel/commit/de5c2e55e1b7cb553ee55437d363fc0a041552c9)) - colection to collection ([50248de](https://github.com/steven-tey/novel/commit/50248dea0bb3463850f7cb1332fbc03377c17594)) - default value and css styles ([ddf0e01](https://github.com/steven-tey/novel/commit/ddf0e0145d2e41dc2ab694bf29fcc9a93d57c04e)) - **docs:** tailwind extensions guide ([7274419](https://github.com/steven-tey/novel/commit/7274419fa74a21b27f8de3db93249e90608ca6ff)) - **docs:** tailwind extensions guide ([a1457c4](https://github.com/steven-tey/novel/commit/a1457c4dfc2f869bb59e8950c49e05a50938b652)) - dont trigger slash-command on codeBlock nodes ([8a3570b](https://github.com/steven-tey/novel/commit/8a3570bd72f32076f9edb42b0492e5eea8d5a014)) - error deploy ([ca06a20](https://github.com/steven-tey/novel/commit/ca06a205fa9ffdc9a8f5b96051cc80244239989d)) - expose editor command list ([8456652](https://github.com/steven-tey/novel/commit/84566528fc3a4c3420c94f68a54a600810e9942c)) - expose utils ([33252ac](https://github.com/steven-tey/novel/commit/33252ac5148437cd4be7be736b7b8f872b07888d)) - format lint ([d4484f9](https://github.com/steven-tey/novel/commit/d4484f96b764f17ac9bcb152db7caa5279ca7666)) - image is deleted if an error occurs ([13e74b3](https://github.com/steven-tey/novel/commit/13e74b3a4b9b77356c895892f0a142d3a872403e)) - keep drag handle ([8363ea6](https://github.com/steven-tey/novel/commit/8363ea60c6bc7bd64c5e8409b38a8696d1d7240f)) - not show bubble menu if editor cannot be editable ([2aeee1b](https://github.com/steven-tey/novel/commit/2aeee1b1f402cd2006a9f86b0450efb8db003e73)) - pass ref to div ([a3cd338](https://github.com/steven-tey/novel/commit/a3cd33888ab47a86a80f000a35b8abd6c663e431)) - remove AI autocomplete default placeholder ([5ca260e](https://github.com/steven-tey/novel/commit/5ca260e4496ec9e4da114cb41ee874090a519c13)) - remove auto joiner ([dbef03c](https://github.com/steven-tey/novel/commit/dbef03c5cafb691c3dc4f8c14e6944707ca70a98)) - remove default extensions and make them standalone exports ([54bfd40](https://github.com/steven-tey/novel/commit/54bfd404b013a094449a2ac16ec47a561ddd15a5)) - remove drag-handle on drop ([1ec5518](https://github.com/steven-tey/novel/commit/1ec551819e2490afddd1e042e0eb70d626c3325d)) - remove katex styling import inside mathematics extension ([91264c7](https://github.com/steven-tey/novel/commit/91264c7c764f8a1b0d3859c3179fc8ccf18f330f)) - rename to workflows ([16d3ff5](https://github.com/steven-tey/novel/commit/16d3ff56a6b1cb291b79c72dcf71ed5735645dcc)) - rename updated image type ([d1b21d6](https://github.com/steven-tey/novel/commit/d1b21d695ba105e3e7f26d607ee8969b92289ff5)) - safari related fix ([40a6fd8](https://github.com/steven-tey/novel/commit/40a6fd8ef3d881a89792e2c078c4f56bde4327ec)) - The tailwind example link on setup page redirects to correct file ([55e4e69](https://github.com/steven-tey/novel/commit/55e4e69da6059023716e08bbb40c87cf829c9588)) - update docs & rename type to EditorInstance ([ff9cf90](https://github.com/steven-tey/novel/commit/ff9cf902581cc2a65167f2b5493c9c183a489817)) - update packge docs ([40b860f](https://github.com/steven-tey/novel/commit/40b860f654483934846fe66991c13906400c0fb1)) - update tiptap-markdown ([d6358fe](https://github.com/steven-tey/novel/commit/d6358fe2c036e2d4c8101abb0912c8faabc9cf60)) - update types ([8d168f5](https://github.com/steven-tey/novel/commit/8d168f58ca8e6cc9a859f232a570a2ded6532367)) - use per-editor instance of tunnel to render slash command popover ([d05c03f](https://github.com/steven-tey/novel/commit/d05c03ff5f668d8b65a1729a9adc2d79315f9591)) - use verbatim import ([1c4df17](https://github.com/steven-tey/novel/commit/1c4df17f252773ca472150541468a01c18d37588)) ### Features - add ai features example ([2a5e18c](https://github.com/steven-tey/novel/commit/2a5e18c8950f2e26659775d812b1be1d56baf169)) - add custom highlight extension ([45efd37](https://github.com/steven-tey/novel/commit/45efd37d157e5f0654fb8ec83e5175df7b2aa918)) - add custom upload config ([a202e6e](https://github.com/steven-tey/novel/commit/a202e6eb14488fe640e082d9fa665ce32ff02f65)) - add dialog usage ([9152e46](https://github.com/steven-tey/novel/commit/9152e461a5ba8fb6480d423b4688a15407365c47)) - add docs step to include editor props ([17dcc6d](https://github.com/steven-tey/novel/commit/17dcc6d94a9d213f86a704c043985b97138912c1)) - add issue template ([2241fd1](https://github.com/steven-tey/novel/commit/2241fd1c5275456f8cd81cffb19dbe9055444bab)) - add mathematics extension ([15b4428](https://github.com/steven-tey/novel/commit/15b44284d60a7ee88da3e1f1ee3462acdc1f8af8)) - add twitter extension ([e019f34](https://github.com/steven-tey/novel/commit/e019f34575b0a9d8a1e1fbc04c72c100780ed035)) - add utils functions for text generation ([7e99b72](https://github.com/steven-tey/novel/commit/7e99b722e393cde51bd206b0d012c130837c7137)) - ai prev markdown ([122b3ee](https://github.com/steven-tey/novel/commit/122b3eed4e748e0824b6a7d670bf26c93bdada5a)) - clear nodes on node selector ([596d811](https://github.com/steven-tey/novel/commit/596d81176030b29dfa1b41ee99797e855e9cafbe)) - configure changeset for release ([c09dd55](https://github.com/steven-tey/novel/commit/c09dd55f0cc271b8d272a03a14a8b6108f611ee5)) - fix biome linting ([081ab3b](https://github.com/steven-tey/novel/commit/081ab3bd6367d6e2e5660da1f2615ce322def85c)) - forward ref components ([957e5dc](https://github.com/steven-tey/novel/commit/957e5dc279c804bab4c4af8a94f9275967fbbc65)) - remove old docs & example add typecheck ([e0b2c99](https://github.com/steven-tey/novel/commit/e0b2c99b913fb283d5b55d94a0837b986936e160)) - support for custom OpenAI base url ([7ac5895](https://github.com/steven-tey/novel/commit/7ac5895b7aece309a1e671bf5fa4d5042db296ea)) - update docs ([9534c6e](https://github.com/steven-tey/novel/commit/9534c6ed78fc5850e46673499117fc144c770058)) - update docs with demo code link ([4569347](https://github.com/steven-tey/novel/commit/4569347e8306517747aa0b5be40c399a286b1b9a)) - use biome for linting & formatting ([e2601a0](https://github.com/steven-tey/novel/commit/e2601a059332e7db580d517f3081d7db555a1fb3)) - use semantic release library ([4854d8a](https://github.com/steven-tey/novel/commit/4854d8a4a1d315dfbd3d96ca9e9a91e4f08afbfe)) # [0.2.0](https://github.com/steven-tey/novel/compare/v0.1.0...v0.2.0) (2025-01-17) ### Bug Fixes - add correct config ([7c2a9a1](https://github.com/steven-tey/novel/commit/7c2a9a1eb79c774e5b11ca1f137f7f28ee4aaadf)) - add correct workflow ([db2781c](https://github.com/steven-tey/novel/commit/db2781c48e1e25517d8c473209b09a06ea2edda4)) - add crazy spinner ([256ab4a](https://github.com/steven-tey/novel/commit/256ab4a03b168ee170e90b91a90ad7b8df0e3ec6)) - add default prose ([cb93667](https://github.com/steven-tey/novel/commit/cb9366704180f9c7b5ccd793806af35d6c8f7d97)) - add docs button ([1cc5140](https://github.com/steven-tey/novel/commit/1cc514089c8ba0b1e19e26e0d72b45c4f68ea360)) - add missing dependecy ([75da619](https://github.com/steven-tey/novel/commit/75da61969b43a6f1b567c624f1a68e119578a589)) - add shorter longer options ([abdd1b7](https://github.com/steven-tey/novel/commit/abdd1b795acf1fe7211e7983171c5c73670b473a)) - build fix & prettier top level ([a8d30fc](https://github.com/steven-tey/novel/commit/a8d30fc28550fa3d1238bca0e4ec0e7a83d5ebdd)) - bump version ([74ebbc0](https://github.com/steven-tey/novel/commit/74ebbc0852deeca76668b3319e490582756c97ed)) - bump version ([2abe146](https://github.com/steven-tey/novel/commit/2abe1460207d78651593cd75990ea39195b8d5b8)) - bump version ([41d7e6a](https://github.com/steven-tey/novel/commit/41d7e6afdf787d82095bef6b819ed7572e3559f8)) - bump version ([02409d1](https://github.com/steven-tey/novel/commit/02409d14918166be52f0976a4a7f5b2df8cd4e81)) - bump version ([4294547](https://github.com/steven-tey/novel/commit/4294547e97bbd5788a4db58d8c75540cb19fc155)) - bump version ([75ed43a](https://github.com/steven-tey/novel/commit/75ed43afe643671b7249874e29e4e62053c98f40)) - bump version ([4c4b28f](https://github.com/steven-tey/novel/commit/4c4b28f981073b79a3068a8d5d1979b55cfdba20)) - cannot set text color when the text is bold ([6db8197](https://github.com/steven-tey/novel/commit/6db81970ebc2773ea723dfe60b644766b0a238dd)) - checkbox fix ([4d49c2f](https://github.com/steven-tey/novel/commit/4d49c2f92ded59c73e5e79ccbc95eed0e75f361a)) - chore bump version ([68243f6](https://github.com/steven-tey/novel/commit/68243f69613a94e414fb401992a613228f40ac98)) - chore bump version ([7051d54](https://github.com/steven-tey/novel/commit/7051d542a78320565d8ce2fe9086967603934ace)) - codeblock-lowlight doesn't render in html output ([de5c2e5](https://github.com/steven-tey/novel/commit/de5c2e55e1b7cb553ee55437d363fc0a041552c9)) - colection to collection ([50248de](https://github.com/steven-tey/novel/commit/50248dea0bb3463850f7cb1332fbc03377c17594)) - default value and css styles ([ddf0e01](https://github.com/steven-tey/novel/commit/ddf0e0145d2e41dc2ab694bf29fcc9a93d57c04e)) - **docs:** tailwind extensions guide ([7274419](https://github.com/steven-tey/novel/commit/7274419fa74a21b27f8de3db93249e90608ca6ff)) - **docs:** tailwind extensions guide ([a1457c4](https://github.com/steven-tey/novel/commit/a1457c4dfc2f869bb59e8950c49e05a50938b652)) - dont trigger slash-command on codeBlock nodes ([8a3570b](https://github.com/steven-tey/novel/commit/8a3570bd72f32076f9edb42b0492e5eea8d5a014)) - error deploy ([ca06a20](https://github.com/steven-tey/novel/commit/ca06a205fa9ffdc9a8f5b96051cc80244239989d)) - expose editor command list ([8456652](https://github.com/steven-tey/novel/commit/84566528fc3a4c3420c94f68a54a600810e9942c)) - expose utils ([33252ac](https://github.com/steven-tey/novel/commit/33252ac5148437cd4be7be736b7b8f872b07888d)) - format lint ([d4484f9](https://github.com/steven-tey/novel/commit/d4484f96b764f17ac9bcb152db7caa5279ca7666)) - image is deleted if an error occurs ([13e74b3](https://github.com/steven-tey/novel/commit/13e74b3a4b9b77356c895892f0a142d3a872403e)) - keep drag handle ([8363ea6](https://github.com/steven-tey/novel/commit/8363ea60c6bc7bd64c5e8409b38a8696d1d7240f)) - not show bubble menu if editor cannot be editable ([2aeee1b](https://github.com/steven-tey/novel/commit/2aeee1b1f402cd2006a9f86b0450efb8db003e73)) - pass ref to div ([a3cd338](https://github.com/steven-tey/novel/commit/a3cd33888ab47a86a80f000a35b8abd6c663e431)) - remove AI autocomplete default placeholder ([5ca260e](https://github.com/steven-tey/novel/commit/5ca260e4496ec9e4da114cb41ee874090a519c13)) - remove auto joiner ([dbef03c](https://github.com/steven-tey/novel/commit/dbef03c5cafb691c3dc4f8c14e6944707ca70a98)) - remove default extensions and make them standalone exports ([54bfd40](https://github.com/steven-tey/novel/commit/54bfd404b013a094449a2ac16ec47a561ddd15a5)) - remove drag-handle on drop ([1ec5518](https://github.com/steven-tey/novel/commit/1ec551819e2490afddd1e042e0eb70d626c3325d)) - remove katex styling import inside mathematics extension ([91264c7](https://github.com/steven-tey/novel/commit/91264c7c764f8a1b0d3859c3179fc8ccf18f330f)) - rename to workflows ([16d3ff5](https://github.com/steven-tey/novel/commit/16d3ff56a6b1cb291b79c72dcf71ed5735645dcc)) - rename updated image type ([d1b21d6](https://github.com/steven-tey/novel/commit/d1b21d695ba105e3e7f26d607ee8969b92289ff5)) - safari related fix ([40a6fd8](https://github.com/steven-tey/novel/commit/40a6fd8ef3d881a89792e2c078c4f56bde4327ec)) - The tailwind example link on setup page redirects to correct file ([55e4e69](https://github.com/steven-tey/novel/commit/55e4e69da6059023716e08bbb40c87cf829c9588)) - update docs & rename type to EditorInstance ([ff9cf90](https://github.com/steven-tey/novel/commit/ff9cf902581cc2a65167f2b5493c9c183a489817)) - update packge docs ([40b860f](https://github.com/steven-tey/novel/commit/40b860f654483934846fe66991c13906400c0fb1)) - update tiptap-markdown ([d6358fe](https://github.com/steven-tey/novel/commit/d6358fe2c036e2d4c8101abb0912c8faabc9cf60)) - update types ([8d168f5](https://github.com/steven-tey/novel/commit/8d168f58ca8e6cc9a859f232a570a2ded6532367)) - use per-editor instance of tunnel to render slash command popover ([d05c03f](https://github.com/steven-tey/novel/commit/d05c03ff5f668d8b65a1729a9adc2d79315f9591)) - use verbatim import ([1c4df17](https://github.com/steven-tey/novel/commit/1c4df17f252773ca472150541468a01c18d37588)) ### Features - add ai features example ([2a5e18c](https://github.com/steven-tey/novel/commit/2a5e18c8950f2e26659775d812b1be1d56baf169)) - add custom highlight extension ([45efd37](https://github.com/steven-tey/novel/commit/45efd37d157e5f0654fb8ec83e5175df7b2aa918)) - add custom upload config ([a202e6e](https://github.com/steven-tey/novel/commit/a202e6eb14488fe640e082d9fa665ce32ff02f65)) - add dialog usage ([9152e46](https://github.com/steven-tey/novel/commit/9152e461a5ba8fb6480d423b4688a15407365c47)) - add docs step to include editor props ([17dcc6d](https://github.com/steven-tey/novel/commit/17dcc6d94a9d213f86a704c043985b97138912c1)) - add issue template ([2241fd1](https://github.com/steven-tey/novel/commit/2241fd1c5275456f8cd81cffb19dbe9055444bab)) - add mathematics extension ([15b4428](https://github.com/steven-tey/novel/commit/15b44284d60a7ee88da3e1f1ee3462acdc1f8af8)) - add twitter extension ([e019f34](https://github.com/steven-tey/novel/commit/e019f34575b0a9d8a1e1fbc04c72c100780ed035)) - add utils functions for text generation ([7e99b72](https://github.com/steven-tey/novel/commit/7e99b722e393cde51bd206b0d012c130837c7137)) - ai prev markdown ([122b3ee](https://github.com/steven-tey/novel/commit/122b3eed4e748e0824b6a7d670bf26c93bdada5a)) - clear nodes on node selector ([596d811](https://github.com/steven-tey/novel/commit/596d81176030b29dfa1b41ee99797e855e9cafbe)) - configure changeset for release ([c09dd55](https://github.com/steven-tey/novel/commit/c09dd55f0cc271b8d272a03a14a8b6108f611ee5)) - fix biome linting ([081ab3b](https://github.com/steven-tey/novel/commit/081ab3bd6367d6e2e5660da1f2615ce322def85c)) - forward ref components ([957e5dc](https://github.com/steven-tey/novel/commit/957e5dc279c804bab4c4af8a94f9275967fbbc65)) - remove old docs & example add typecheck ([e0b2c99](https://github.com/steven-tey/novel/commit/e0b2c99b913fb283d5b55d94a0837b986936e160)) - support for custom OpenAI base url ([7ac5895](https://github.com/steven-tey/novel/commit/7ac5895b7aece309a1e671bf5fa4d5042db296ea)) - update docs ([9534c6e](https://github.com/steven-tey/novel/commit/9534c6ed78fc5850e46673499117fc144c770058)) - update docs with demo code link ([4569347](https://github.com/steven-tey/novel/commit/4569347e8306517747aa0b5be40c399a286b1b9a)) - use biome for linting & formatting ([e2601a0](https://github.com/steven-tey/novel/commit/e2601a059332e7db580d517f3081d7db555a1fb3)) - use semantic release library ([4854d8a](https://github.com/steven-tey/novel/commit/4854d8a4a1d315dfbd3d96ca9e9a91e4f08afbfe)) # novel ## 0.5.0 ### Minor Changes - update extensions export ## 0.4.3 ### Patch Changes - add twitter extension ## 0.4.2 ### Patch Changes - bump version ## 0.4.1 ### Patch Changes - expose utils ## 0.4.0 ### Minor Changes - expose utils fix bugs ## 0.3.1 ### Patch Changes - regression fix ## 0.3.0 ### Minor Changes - update drag handle ## 0.2.13 ### Patch Changes - small fixes ## 0.2.12 ### Patch Changes - Expose command list editor ## 0.2.11 ### Patch Changes - Ai utils & generative example ## 0.2.10 ### Patch Changes - Fix types ## 0.2.9 ### Patch Changes - Custom upload config ## 0.2.8 ### Patch Changes - Code quality and extensions fixing ## 0.2.7 ### Patch Changes - [#311](https://github.com/steven-tey/novel/pull/311) [`c09dd55`](https://github.com/steven-tey/novel/commit/c09dd55f0cc271b8d272a03a14a8b6108f611ee5) Thanks [@andrewdoro](https://github.com/andrewdoro)! - Rename type from Editor to EditorInstance ================================================ FILE: packages/headless/biome.json ================================================ { "extends": ["../../biome.json"] } ================================================ FILE: packages/headless/package.json ================================================ { "name": "novel", "version": "1.0.0", "description": "Notion-style WYSIWYG editor with AI-powered autocompletions", "license": "Apache-2.0", "type": "module", "main": "dist/index.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", "files": [ "dist" ], "scripts": { "dev": "tsup --watch", "typecheck": "tsc --noEmit", "build": "tsup", "lint": "biome lint ./src", "format": "biome format ./src " }, "sideEffects": false, "peerDependencies": { "react": ">=18" }, "dependencies": { "@radix-ui/react-slot": "^1.1.1", "@tiptap/core": "^2.11.2", "@tiptap/extension-character-count": "^2.11.2", "@tiptap/extension-code-block-lowlight": "^2.11.2", "@tiptap/extension-color": "^2.11.2", "@tiptap/extension-highlight": "^2.11.2", "@tiptap/extension-horizontal-rule": "^2.11.2", "@tiptap/extension-image": "^2.11.2", "@tiptap/extension-link": "^2.11.2", "@tiptap/extension-placeholder": "^2.11.2", "@tiptap/extension-task-item": "^2.11.2", "@tiptap/extension-task-list": "^2.11.2", "@tiptap/extension-text-style": "^2.11.2", "@tiptap/extension-underline": "^2.11.2", "@tiptap/extension-youtube": "^2.11.2", "@tiptap/pm": "^2.11.2", "@tiptap/react": "^2.11.2", "@tiptap/starter-kit": "^2.11.2", "@tiptap/suggestion": "^2.11.2", "@types/node": "^22.10.6", "cmdk": "^1.0.4", "jotai": "^2.11.0", "react-markdown": "^9.0.3", "react-moveable": "^0.56.0", "react-tweet": "^3.2.1", "katex": "^0.16.20", "tippy.js": "^6.3.7", "tiptap-extension-global-drag-handle": "^0.1.16", "tiptap-markdown": "^0.8.10", "tunnel-rat": "^0.1.2" }, "devDependencies": { "@biomejs/biome": "^1.9.4", "@types/katex": "^0.16.7", "@types/react": "^18.2.55", "@types/react-dom": "18.2.19", "tsconfig": "workspace:*", "tsup": "^8.3.5", "typescript": "^5.7.3" }, "author": "Steven Tey ", "homepage": "https://novel.sh", "repository": { "type": "git", "url": "git+https://github.com/steven-tey/novel.git" }, "bugs": { "url": "https://github.com/steven-tey/novel/issues" }, "keywords": [ "ai", "novel", "editor", "markdown", "nextjs", "react" ] } ================================================ FILE: packages/headless/src/components/editor-bubble-item.tsx ================================================ import { forwardRef } from "react"; import { Slot } from "@radix-ui/react-slot"; import { useCurrentEditor } from "@tiptap/react"; import type { Editor } from "@tiptap/react"; import type { ComponentPropsWithoutRef, ReactNode } from "react"; interface EditorBubbleItemProps { readonly children: ReactNode; readonly asChild?: boolean; readonly onSelect?: (editor: Editor) => void; } export const EditorBubbleItem = forwardRef< HTMLDivElement, EditorBubbleItemProps & Omit, "onSelect"> >(({ children, asChild, onSelect, ...rest }, ref) => { const { editor } = useCurrentEditor(); const Comp = asChild ? Slot : "div"; if (!editor) return null; return ( onSelect?.(editor)}> {children} ); }); EditorBubbleItem.displayName = "EditorBubbleItem"; export default EditorBubbleItem; ================================================ FILE: packages/headless/src/components/editor-bubble.tsx ================================================ import { BubbleMenu, isNodeSelection, useCurrentEditor } from "@tiptap/react"; import type { BubbleMenuProps } from "@tiptap/react"; import { forwardRef, useEffect, useMemo, useRef } from "react"; import type { ReactNode } from "react"; import type { Instance, Props } from "tippy.js"; export interface EditorBubbleProps extends Omit { readonly children: ReactNode; } export const EditorBubble = forwardRef( ({ children, tippyOptions, ...rest }, ref) => { const { editor: currentEditor } = useCurrentEditor(); const instanceRef = useRef | null>(null); useEffect(() => { if (!instanceRef.current || !tippyOptions?.placement) return; instanceRef.current.setProps({ placement: tippyOptions.placement }); instanceRef.current.popperInstance?.update(); }, [tippyOptions?.placement]); const bubbleMenuProps: Omit = useMemo(() => { const shouldShow: BubbleMenuProps["shouldShow"] = ({ editor, state }) => { const { selection } = state; const { empty } = selection; // don't show bubble menu if: // - the editor is not editable // - the selected node is an image // - the selection is empty // - the selection is a node selection (for drag handles) if (!editor.isEditable || editor.isActive("image") || empty || isNodeSelection(selection)) { return false; } return true; }; return { shouldShow, tippyOptions: { onCreate: (val) => { instanceRef.current = val; instanceRef.current.popper.firstChild?.addEventListener("blur", (event) => { event.preventDefault(); event.stopImmediatePropagation(); }); }, moveTransition: "transform 0.15s ease-out", ...tippyOptions, }, editor: currentEditor, ...rest, }; }, [rest, tippyOptions]); if (!currentEditor) return null; return ( // We need to add this because of https://github.com/ueberdosis/tiptap/issues/2658
{children}
); }, ); EditorBubble.displayName = "EditorBubble"; export default EditorBubble; ================================================ FILE: packages/headless/src/components/editor-command-item.tsx ================================================ import { forwardRef } from "react"; import { CommandEmpty, CommandItem } from "cmdk"; import { useCurrentEditor } from "@tiptap/react"; import { useAtomValue } from "jotai"; import { rangeAtom } from "../utils/atoms"; import type { ComponentPropsWithoutRef } from "react"; import type { Editor, Range } from "@tiptap/core"; interface EditorCommandItemProps { readonly onCommand: ({ editor, range, }: { editor: Editor; range: Range; }) => void; } export const EditorCommandItem = forwardRef< HTMLDivElement, EditorCommandItemProps & ComponentPropsWithoutRef >(({ children, onCommand, ...rest }, ref) => { const { editor } = useCurrentEditor(); const range = useAtomValue(rangeAtom); if (!editor || !range) return null; return ( onCommand({ editor, range })}> {children} ); }); EditorCommandItem.displayName = "EditorCommandItem"; export const EditorCommandEmpty = CommandEmpty; export default EditorCommandItem; ================================================ FILE: packages/headless/src/components/editor-command.tsx ================================================ import { useAtom, useSetAtom } from "jotai"; import { useEffect, forwardRef, createContext } from "react"; import { Command } from "cmdk"; import { queryAtom, rangeAtom } from "../utils/atoms"; import { novelStore } from "../utils/store"; import type tunnel from "tunnel-rat"; import type { ComponentPropsWithoutRef, FC } from "react"; import type { Range } from "@tiptap/core"; export const EditorCommandTunnelContext = createContext({} as ReturnType); interface EditorCommandOutProps { readonly query: string; readonly range: Range; } export const EditorCommandOut: FC = ({ query, range }) => { const setQuery = useSetAtom(queryAtom, { store: novelStore }); const setRange = useSetAtom(rangeAtom, { store: novelStore }); useEffect(() => { setQuery(query); }, [query, setQuery]); useEffect(() => { setRange(range); }, [range, setRange]); useEffect(() => { const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"]; const onKeyDown = (e: KeyboardEvent) => { if (navigationKeys.includes(e.key)) { e.preventDefault(); const commandRef = document.querySelector("#slash-command"); if (commandRef) commandRef.dispatchEvent( new KeyboardEvent("keydown", { key: e.key, cancelable: true, bubbles: true, }), ); return false; } }; document.addEventListener("keydown", onKeyDown); return () => { document.removeEventListener("keydown", onKeyDown); }; }, []); return ( {(tunnelInstance) => } ); }; export const EditorCommand = forwardRef>( ({ children, className, ...rest }, ref) => { const [query, setQuery] = useAtom(queryAtom); return ( {(tunnelInstance) => ( { e.stopPropagation(); }} id="slash-command" className={className} {...rest} > {children} )} ); }, ); export const EditorCommandList = Command.List; EditorCommand.displayName = "EditorCommand"; ================================================ FILE: packages/headless/src/components/editor.tsx ================================================ import { EditorProvider } from "@tiptap/react"; import type { EditorProviderProps, JSONContent } from "@tiptap/react"; import { Provider } from "jotai"; import { forwardRef, useRef } from "react"; import type { FC, ReactNode } from "react"; import tunnel from "tunnel-rat"; import { novelStore } from "../utils/store"; import { EditorCommandTunnelContext } from "./editor-command"; export interface EditorProps { readonly children: ReactNode; readonly className?: string; } interface EditorRootProps { readonly children: ReactNode; } export const EditorRoot: FC = ({ children }) => { const tunnelInstance = useRef(tunnel()).current; return ( {children} ); }; export type EditorContentProps = Omit & { readonly children?: ReactNode; readonly className?: string; readonly initialContent?: JSONContent; }; export const EditorContent = forwardRef( ({ className, children, initialContent, ...rest }, ref) => (
{children}
), ); EditorContent.displayName = "EditorContent"; ================================================ FILE: packages/headless/src/components/index.ts ================================================ export { useCurrentEditor as useEditor } from "@tiptap/react"; export { type Editor as EditorInstance } from "@tiptap/core"; export type { JSONContent } from "@tiptap/react"; export { EditorRoot, EditorContent, type EditorContentProps } from "./editor"; export { EditorBubble } from "./editor-bubble"; export { EditorBubbleItem } from "./editor-bubble-item"; export { EditorCommand, EditorCommandList } from "./editor-command"; export { EditorCommandItem, EditorCommandEmpty } from "./editor-command-item"; ================================================ FILE: packages/headless/src/extensions/ai-highlight.ts ================================================ import { type Editor, Mark, markInputRule, markPasteRule, mergeAttributes } from "@tiptap/core"; export interface AIHighlightOptions { HTMLAttributes: Record; } declare module "@tiptap/core" { interface Commands { AIHighlight: { /** * Set a AIHighlight mark */ setAIHighlight: (attributes?: { color: string }) => ReturnType; /** * Toggle a AIHighlight mark */ toggleAIHighlight: (attributes?: { color: string }) => ReturnType; /** * Unset a AIHighlight mark */ unsetAIHighlight: () => ReturnType; }; } } export const inputRegex = /(?:^|\s)((?:==)((?:[^~=]+))(?:==))$/; export const pasteRegex = /(?:^|\s)((?:==)((?:[^~=]+))(?:==))/g; export const AIHighlight = Mark.create({ name: "ai-highlight", addOptions() { return { HTMLAttributes: {}, }; }, addAttributes() { return { color: { default: null, parseHTML: (element) => element.getAttribute("data-color") || element.style.backgroundColor, renderHTML: (attributes) => { if (!attributes.color) { return {}; } return { "data-color": attributes.color, style: `background-color: ${attributes.color}; color: inherit`, }; }, }, }; }, parseHTML() { return [ { tag: "mark", }, ]; }, renderHTML({ HTMLAttributes }) { return ["mark", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; }, addCommands() { return { setAIHighlight: (attributes) => ({ commands }) => { return commands.setMark(this.name, attributes); }, toggleAIHighlight: (attributes) => ({ commands }) => { return commands.toggleMark(this.name, attributes); }, unsetAIHighlight: () => ({ commands }) => { return commands.unsetMark(this.name); }, }; }, addKeyboardShortcuts() { return { "Mod-Shift-h": () => this.editor.commands.toggleAIHighlight(), }; }, addInputRules() { return [ markInputRule({ find: inputRegex, type: this.type, }), ]; }, addPasteRules() { return [ markPasteRule({ find: pasteRegex, type: this.type, }), ]; }, }); export const removeAIHighlight = (editor: Editor) => { const tr = editor.state.tr; tr.removeMark(0, editor.state.doc.nodeSize - 2, editor.state.schema.marks["ai-highlight"]); editor.view.dispatch(tr); }; export const addAIHighlight = (editor: Editor, color?: string) => { editor .chain() .setAIHighlight({ color: color ?? "#c1ecf970" }) .run(); }; ================================================ FILE: packages/headless/src/extensions/custom-keymap.ts ================================================ import { Extension } from "@tiptap/core"; declare module "@tiptap/core" { // eslint-disable-next-line no-unused-vars interface Commands { customkeymap: { /** * Select text between node boundaries */ selectTextWithinNodeBoundaries: () => ReturnType; }; } } const CustomKeymap = Extension.create({ name: "CustomKeymap", addCommands() { return { selectTextWithinNodeBoundaries: () => ({ editor, commands }) => { const { state } = editor; const { tr } = state; const startNodePos = tr.selection.$from.start(); const endNodePos = tr.selection.$to.end(); return commands.setTextSelection({ from: startNodePos, to: endNodePos, }); }, }; }, addKeyboardShortcuts() { return { "Mod-a": ({ editor }) => { const { state } = editor; const { tr } = state; const startSelectionPos = tr.selection.from; const endSelectionPos = tr.selection.to; const startNodePos = tr.selection.$from.start(); const endNodePos = tr.selection.$to.end(); const isCurrentTextSelectionNotExtendedToNodeBoundaries = startSelectionPos > startNodePos || endSelectionPos < endNodePos; if (isCurrentTextSelectionNotExtendedToNodeBoundaries) { editor.chain().selectTextWithinNodeBoundaries().run(); return true; } return false; }, }; }, }); export default CustomKeymap; ================================================ FILE: packages/headless/src/extensions/image-resizer.tsx ================================================ import { useCurrentEditor } from "@tiptap/react"; import type { FC } from "react"; import Moveable from "react-moveable"; export const ImageResizer: FC = () => { const { editor } = useCurrentEditor(); if (!editor?.isActive("image")) return null; const updateMediaSize = () => { const imageInfo = document.querySelector(".ProseMirror-selectednode") as HTMLImageElement; if (imageInfo) { const selection = editor.state.selection; const setImage = editor.commands.setImage as (options: { src: string; width: number; height: number; }) => boolean; setImage({ src: imageInfo.src, width: Number(imageInfo.style.width.replace("px", "")), height: Number(imageInfo.style.height.replace("px", "")), }); editor.commands.setNodeSelection(selection.from); } }; return ( { if (delta[0]) target.style.width = `${width}px`; if (delta[1]) target.style.height = `${height}px`; }} // { target, isDrag, clientX, clientY }: any onResizeEnd={() => { updateMediaSize(); }} /* scalable */ /* Only one of resizable, scalable, warpable can be used. */ scalable={true} throttleScale={0} /* Set the direction of resizable */ renderDirections={["w", "e"]} onScale={({ target, // scale, // dist, // delta, transform, }) => { target.style.transform = transform; }} /> ); }; ================================================ FILE: packages/headless/src/extensions/index.ts ================================================ import { InputRule } from "@tiptap/core"; import { Color } from "@tiptap/extension-color"; import Highlight from "@tiptap/extension-highlight"; import HorizontalRule from "@tiptap/extension-horizontal-rule"; import TiptapImage from "@tiptap/extension-image"; import TiptapLink from "@tiptap/extension-link"; import Placeholder from "@tiptap/extension-placeholder"; import { TaskItem } from "@tiptap/extension-task-item"; import { TaskList } from "@tiptap/extension-task-list"; import TextStyle from "@tiptap/extension-text-style"; import TiptapUnderline from "@tiptap/extension-underline"; import StarterKit from "@tiptap/starter-kit"; import { Markdown } from "tiptap-markdown"; import CustomKeymap from "./custom-keymap"; import { ImageResizer } from "./image-resizer"; import { Twitter } from "./twitter"; import { Mathematics } from "./mathematics"; import UpdatedImage from "./updated-image"; import CharacterCount from "@tiptap/extension-character-count"; import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; import Youtube from "@tiptap/extension-youtube"; import GlobalDragHandle from "tiptap-extension-global-drag-handle"; const PlaceholderExtension = Placeholder.configure({ placeholder: ({ node }) => { if (node.type.name === "heading") { return `Heading ${node.attrs.level}`; } return "Press '/' for commands"; }, includeChildren: true, }); const HighlightExtension = Highlight.configure({ multicolor: true, }); const MarkdownExtension = Markdown.configure({ html: false, transformCopiedText: true, }); const Horizontal = HorizontalRule.extend({ addInputRules() { return [ new InputRule({ find: /^(?:---|—-|___\s|\*\*\*\s)$/u, handler: ({ state, range }) => { const attributes = {}; const { tr } = state; const start = range.from; const end = range.to; tr.insert(start - 1, this.type.create(attributes)).delete(tr.mapping.map(start), tr.mapping.map(end)); }, }), ]; }, }); export * from "./ai-highlight"; export * from "./slash-command"; export { CodeBlockLowlight, Horizontal as HorizontalRule, ImageResizer, InputRule, PlaceholderExtension as Placeholder, StarterKit, TaskItem, TaskList, TiptapImage, TiptapUnderline, MarkdownExtension, TextStyle, Color, HighlightExtension, CustomKeymap, TiptapLink, UpdatedImage, Youtube, Twitter, Mathematics, CharacterCount, GlobalDragHandle, }; ================================================ FILE: packages/headless/src/extensions/mathematics.ts ================================================ import { Node, mergeAttributes } from "@tiptap/core"; import { EditorState } from "@tiptap/pm/state"; import katex, { type KatexOptions } from "katex"; export interface MathematicsOptions { /** * By default LaTeX decorations can render when mathematical expressions are not inside a code block. * @param state - EditorState * @param pos - number * @returns boolean */ shouldRender: (state: EditorState, pos: number) => boolean; /** * @see https://katex.org/docs/options.html */ katexOptions?: KatexOptions; HTMLAttributes: Record; } declare module "@tiptap/core" { interface Commands { LatexCommand: { /** * Set selection to a LaTex symbol */ setLatex: ({ latex }: { latex: string }) => ReturnType; /** * Unset a LaTex symbol */ unsetLatex: () => ReturnType; }; } } /** * This extension adds support for mathematical symbols with LaTex expression. * * NOTE: Don't forget to import `katex/dist/katex.min.css` CSS for KaTex styling. * * @see https://katex.org/ */ export const Mathematics = Node.create({ name: "math", inline: true, group: "inline", atom: true, selectable: true, marks: "", addAttributes() { return { latex: "", }; }, addOptions() { return { shouldRender: (state, pos) => { const $pos = state.doc.resolve(pos); if (!$pos.parent.isTextblock) { return false; } return $pos.parent.type.name !== "codeBlock"; }, katexOptions: { throwOnError: false, }, HTMLAttributes: {}, }; }, addCommands() { return { setLatex: ({ latex }) => ({ chain, state }) => { if (!latex) { return false; } const { from, to, $anchor } = state.selection; if (!this.options.shouldRender(state, $anchor.pos)) { return false; } return chain() .insertContentAt( { from: from, to: to }, { type: "math", attrs: { latex: latex, }, } ) .setTextSelection({ from: from, to: from + 1 }) .run(); }, unsetLatex: () => ({ editor, state, chain }) => { const latex = editor.getAttributes(this.name).latex; if (typeof latex !== "string") { return false; } const { from, to } = state.selection; return chain() .command(({ tr }) => { tr.insertText(latex, from, to); return true; }) .setTextSelection({ from: from, to: from + latex.length, }) .run(); }, }; }, parseHTML() { return [{ tag: `span[data-type="${this.name}"]` }]; }, renderHTML({ node, HTMLAttributes }) { const latex = node.attrs["latex"] ?? ""; return [ "span", mergeAttributes(HTMLAttributes, { "data-type": this.name, }), latex, ]; }, renderText({ node }) { return node.attrs["latex"] ?? ""; }, addNodeView() { return ({ node, HTMLAttributes, getPos, editor }) => { const dom = document.createElement("span"); const latex: string = node.attrs["latex"] ?? ""; Object.entries(this.options.HTMLAttributes).forEach(([key, value]) => { dom.setAttribute(key, value); }); Object.entries(HTMLAttributes).forEach(([key, value]) => { dom.setAttribute(key, value); }); dom.addEventListener("click", (evt) => { if (editor.isEditable && typeof getPos === "function") { const pos = getPos(); const nodeSize = node.nodeSize; editor.commands.setTextSelection({ from: pos, to: pos + nodeSize }); } }); dom.contentEditable = "false"; dom.innerHTML = katex.renderToString(latex, this.options.katexOptions); return { dom: dom, }; }; }, }); ================================================ FILE: packages/headless/src/extensions/slash-command.tsx ================================================ import { Extension } from "@tiptap/core"; import type { Editor, Range } from "@tiptap/core"; import { ReactRenderer } from "@tiptap/react"; import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion"; import type { RefObject } from "react"; import type { ReactNode } from "react"; import tippy, { type GetReferenceClientRect, type Instance, type Props } from "tippy.js"; import { EditorCommandOut } from "../components/editor-command"; const Command = Extension.create({ name: "slash-command", addOptions() { return { suggestion: { char: "/", command: ({ editor, range, props }) => { props.command({ editor, range }); }, } as SuggestionOptions, }; }, addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, ...this.options.suggestion, }), ]; }, }); const renderItems = (elementRef?: RefObject | null) => { let component: ReactRenderer | null = null; let popup: Instance[] | null = null; return { onStart: (props: { editor: Editor; clientRect: DOMRect }) => { component = new ReactRenderer(EditorCommandOut, { props, editor: props.editor, }); const { selection } = props.editor.state; const parentNode = selection.$from.node(selection.$from.depth); const blockType = parentNode.type.name; if (blockType === "codeBlock") { return false; } // @ts-ignore popup = tippy("body", { getReferenceClientRect: props.clientRect, appendTo: () => (elementRef ? elementRef.current : document.body), content: component.element, showOnCreate: true, interactive: true, trigger: "manual", placement: "bottom-start", }); }, onUpdate: (props: { editor: Editor; clientRect: GetReferenceClientRect }) => { component?.updateProps(props); popup?.[0]?.setProps({ getReferenceClientRect: props.clientRect, }); }, onKeyDown: (props: { event: KeyboardEvent }) => { if (props.event.key === "Escape") { popup?.[0]?.hide(); return true; } // @ts-ignore return component?.ref?.onKeyDown(props); }, onExit: () => { popup?.[0]?.destroy(); component?.destroy(); }, }; }; export interface SuggestionItem { title: string; description: string; icon: ReactNode; searchTerms?: string[]; command?: (props: { editor: Editor; range: Range }) => void; } export const createSuggestionItems = (items: SuggestionItem[]) => items; export const handleCommandNavigation = (event: KeyboardEvent) => { if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) { const slashCommand = document.querySelector("#slash-command"); if (slashCommand) { return true; } } }; export { Command, renderItems }; ================================================ FILE: packages/headless/src/extensions/twitter.tsx ================================================ import { Node, mergeAttributes, nodePasteRule } from "@tiptap/core"; import { NodeViewWrapper, ReactNodeViewRenderer, type ReactNodeViewRendererOptions } from "@tiptap/react"; import { Tweet } from "react-tweet"; export const TWITTER_REGEX_GLOBAL = /(https?:\/\/)?(www\.)?x\.com\/([a-zA-Z0-9_]{1,15})(\/status\/(\d+))?(\/\S*)?/g; export const TWITTER_REGEX = /^https?:\/\/(www\.)?x\.com\/([a-zA-Z0-9_]{1,15})(\/status\/(\d+))?(\/\S*)?$/; export const isValidTwitterUrl = (url: string) => { return url.match(TWITTER_REGEX); }; const TweetComponent = ({ node }: { node: Partial }) => { const url = (node?.attrs as Record)?.src; const tweetId = url?.split("/").pop(); if (!tweetId) { return null; } return (
); }; export interface TwitterOptions { /** * Controls if the paste handler for tweets should be added. * @default true * @example false */ addPasteHandler: boolean; // biome-ignore lint/suspicious/noExplicitAny: HTMLAttributes: Record; /** * Controls if the twitter node should be inline or not. * @default false * @example true */ inline: boolean; /** * The origin of the tweet. * @default '' * @example 'https://tiptap.dev' */ origin: string; } /** * The options for setting a tweet. */ type SetTweetOptions = { src: string }; declare module "@tiptap/core" { interface Commands { twitter: { /** * Insert a tweet * @param options The tweet attributes * @example editor.commands.setTweet({ src: 'https://x.com/seanpk/status/1800145949580517852' }) */ setTweet: (options: SetTweetOptions) => ReturnType; }; } } /** * This extension adds support for tweets. */ export const Twitter = Node.create({ name: "twitter", addOptions() { return { addPasteHandler: true, HTMLAttributes: {}, inline: false, origin: "", }; }, addNodeView() { return ReactNodeViewRenderer(TweetComponent, { attrs: this.options.HTMLAttributes }); }, inline() { return this.options.inline; }, group() { return this.options.inline ? "inline" : "block"; }, draggable: true, addAttributes() { return { src: { default: null, }, }; }, parseHTML() { return [ { tag: "div[data-twitter]", }, ]; }, addCommands() { return { setTweet: (options: SetTweetOptions) => ({ commands }) => { if (!isValidTwitterUrl(options.src)) { return false; } return commands.insertContent({ type: this.name, attrs: options, }); }, }; }, addPasteRules() { if (!this.options.addPasteHandler) { return []; } return [ nodePasteRule({ find: TWITTER_REGEX_GLOBAL, type: this.type, getAttributes: (match) => { return { src: match.input }; }, }), ]; }, renderHTML({ HTMLAttributes }) { return ["div", mergeAttributes({ "data-twitter": "" }, HTMLAttributes)]; }, }); ================================================ FILE: packages/headless/src/extensions/updated-image.ts ================================================ import Image from "@tiptap/extension-image"; const UpdatedImage = Image.extend({ name: "image", addAttributes() { return { ...this.parent?.(), width: { default: null, }, height: { default: null, }, }; }, }); export default UpdatedImage; ================================================ FILE: packages/headless/src/index.ts ================================================ // Components export { EditorRoot, EditorContent, type EditorContentProps, EditorBubble, EditorBubbleItem, EditorCommand, EditorCommandList, EditorCommandItem, EditorCommandEmpty, useEditor, type EditorInstance, type JSONContent, } from "./components"; // Extensions export { AIHighlight, removeAIHighlight, addAIHighlight, CodeBlockLowlight, HorizontalRule, ImageResizer, InputRule, Placeholder, StarterKit, TaskItem, TaskList, TiptapImage, TiptapUnderline, MarkdownExtension, TextStyle, Color, HighlightExtension, CustomKeymap, TiptapLink, UpdatedImage, Youtube, Twitter, Mathematics, CharacterCount, GlobalDragHandle, Command, renderItems, createSuggestionItems, handleCommandNavigation, type SuggestionItem, } from "./extensions"; // Plugins export { UploadImagesPlugin, type UploadFn, type ImageUploadOptions, createImageUpload, handleImageDrop, handleImagePaste, } from "./plugins"; // Utils export { isValidUrl, getUrlFromString, getPrevText, getAllContent, } from "./utils"; // Store and Atoms export { queryAtom, rangeAtom } from "./utils/atoms"; ================================================ FILE: packages/headless/src/plugins/index.ts ================================================ export { UploadImagesPlugin, type UploadFn, type ImageUploadOptions, createImageUpload, handleImageDrop, handleImagePaste, } from "./upload-images"; ================================================ FILE: packages/headless/src/plugins/upload-images.tsx ================================================ import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view"; const uploadKey = new PluginKey("upload-image"); export const UploadImagesPlugin = ({ imageClass }: { imageClass: string }) => new Plugin({ key: uploadKey, state: { init() { return DecorationSet.empty; }, apply(tr, set) { set = set.map(tr.mapping, tr.doc); // See if the transaction adds or removes any placeholders //@ts-expect-error - not yet sure what the type I need here const action = tr.getMeta(this); if (action?.add) { const { id, pos, src } = action.add; const placeholder = document.createElement("div"); placeholder.setAttribute("class", "img-placeholder"); const image = document.createElement("img"); image.setAttribute("class", imageClass); image.src = src; placeholder.appendChild(image); const deco = Decoration.widget(pos + 1, placeholder, { id, }); set = set.add(tr.doc, [deco]); } else if (action?.remove) { // biome-ignore lint/suspicious/noDoubleEquals: set = set.remove(set.find(undefined, undefined, (spec) => spec.id == action.remove.id)); } return set; }, }, props: { decorations(state) { return this.getState(state); }, }, }); // biome-ignore lint/complexity/noBannedTypes: function findPlaceholder(state: EditorState, id: {}) { const decos = uploadKey.getState(state) as DecorationSet; // biome-ignore lint/suspicious/noDoubleEquals: const found = decos.find(undefined, undefined, (spec) => spec.id == id); return found.length ? found[0]?.from : null; } export interface ImageUploadOptions { validateFn?: (file: File) => void; onUpload: (file: File) => Promise; } export const createImageUpload = ({ validateFn, onUpload }: ImageUploadOptions): UploadFn => (file, view, pos) => { // check if the file is an image const validated = validateFn?.(file); if (!validated) return; // A fresh object to act as the ID for this upload const id = {}; // Replace the selection with a placeholder const tr = view.state.tr; if (!tr.selection.empty) tr.deleteSelection(); const reader = new FileReader(); reader.readAsDataURL(file); reader.onload = () => { tr.setMeta(uploadKey, { add: { id, pos, src: reader.result, }, }); view.dispatch(tr); }; onUpload(file).then((src) => { const { schema } = view.state; const pos = findPlaceholder(view.state, id); // If the content around the placeholder has been deleted, drop // the image if (pos == null) return; // Otherwise, insert it at the placeholder's position, and remove // the placeholder // When BLOB_READ_WRITE_TOKEN is not valid or unavailable, read // the image locally const imageSrc = typeof src === "object" ? reader.result : src; const node = schema.nodes.image?.create({ src: imageSrc }); if (!node) return; const transaction = view.state.tr.replaceWith(pos, pos, node).setMeta(uploadKey, { remove: { id } }); view.dispatch(transaction); }, () => { // Deletes the image placeholder on error const transaction = view.state.tr .delete(pos, pos) .setMeta(uploadKey, { remove: { id } }); view.dispatch(transaction); }); }; export type UploadFn = (file: File, view: EditorView, pos: number) => void; export const handleImagePaste = (view: EditorView, event: ClipboardEvent, uploadFn: UploadFn) => { if (event.clipboardData?.files.length) { event.preventDefault(); const [file] = Array.from(event.clipboardData.files); const pos = view.state.selection.from; if (file) uploadFn(file, view, pos); return true; } return false; }; export const handleImageDrop = (view: EditorView, event: DragEvent, moved: boolean, uploadFn: UploadFn) => { if (!moved && event.dataTransfer?.files.length) { event.preventDefault(); const [file] = Array.from(event.dataTransfer.files); const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY, }); // here we deduct 1 from the pos or else the image will create an extra node if (file) uploadFn(file, view, coordinates?.pos ?? 0 - 1); return true; } return false; }; ================================================ FILE: packages/headless/src/utils/atoms.ts ================================================ import { atom } from "jotai"; import type { Range } from "@tiptap/core"; export const queryAtom = atom(""); export const rangeAtom = atom(null); ================================================ FILE: packages/headless/src/utils/index.ts ================================================ import { Fragment, type Node } from "@tiptap/pm/model"; import type { EditorInstance } from "../components"; export function isValidUrl(url: string) { try { new URL(url); return true; } catch (_e) { return false; } } export function getUrlFromString(str: string) { if (isValidUrl(str)) return str; try { if (str.includes(".") && !str.includes(" ")) { return new URL(`https://${str}`).toString(); } } catch (_e) { return null; } } // Get the text before a given position in markdown format export const getPrevText = (editor: EditorInstance, position: number) => { const nodes: Node[] = []; editor.state.doc.forEach((node, pos) => { if (pos >= position) return false; nodes.push(node); return true; }); const fragment = Fragment.fromArray(nodes); const doc = editor.state.doc.copy(fragment); return editor.storage.markdown.serializer.serialize(doc) as string; }; // Get all content from the editor in markdown format export const getAllContent = (editor: EditorInstance) => { const fragment = editor.state.doc.content; const doc = editor.state.doc.copy(fragment); return editor.storage.markdown.serializer.serialize(doc) as string; }; ================================================ FILE: packages/headless/src/utils/store.ts ================================================ import { createStore } from "jotai"; // biome-ignore lint/suspicious/noExplicitAny: export const novelStore: any = createStore(); export * from "jotai"; ================================================ FILE: packages/headless/tsconfig.json ================================================ { "extends": "tsconfig/react.json", "include": ["./src/**/*"], "exclude": ["dist", "build", "node_modules"], "compilerOptions": { "declarationMap": false, "outDir": "dist" } } ================================================ FILE: packages/headless/tsup.config.ts ================================================ import { defineConfig, Options } from "tsup"; export default defineConfig((options: Options) => ({ entry: ["src/index.ts"], banner: { js: "'use client'", }, minify: true, format: ["cjs", "esm"], dts: true, clean: true, external: ["react", "react-dom"], ...options, })); ================================================ FILE: packages/tsconfig/base.json ================================================ { "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "alwaysStrict": false, "module": "ESNext", "moduleResolution": "Bundler", "resolveJsonModule": true, "target": "ESNext", "lib": ["DOM", "DOM.Iterable", "ESNext"], "noEmit": true, "declaration": true, "declarationMap": true, "verbatimModuleSyntax": true, "moduleDetection": "force", "downlevelIteration": true, "allowJs": true, "isolatedModules": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true, "skipDefaultLibCheck": true, "incremental": true, "tsBuildInfoFile": ".tsbuildinfo" }, "include": ["**/*.ts", "**/*.tsx"], "exclude": ["node_modules", "src/tests"] } ================================================ FILE: packages/tsconfig/next.json ================================================ { "$schema": "https://json.schemastore.org/tsconfig", "display": "Next.js", "extends": "./base.json", "compilerOptions": { "plugins": [{ "name": "next" }], "allowJs": true, "skipLibCheck": true, "strict": false, "forceConsistentCasingInFileNames": true, "noEmit": true, "incremental": true, "esModuleInterop": true, "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve" }, "include": ["src", "next-env.d.ts"] } ================================================ FILE: packages/tsconfig/package.json ================================================ { "name": "tsconfig", "version": "0.0.0", "private": true, "license": "MIT", "publishConfig": { "access": "public" } } ================================================ FILE: packages/tsconfig/react.json ================================================ { "$schema": "https://json.schemastore.org/tsconfig", "display": "React Library", "extends": "./base.json", "compilerOptions": { "lib": ["DOM"], "target": "ESNext", "jsx": "react-jsx" } } ================================================ FILE: pnpm-workspace.yaml ================================================ packages: - "apps/*" - "packages/*" ================================================ FILE: prettier.config.js ================================================ module.exports = { bracketSpacing: true, semi: true, trailingComma: "all", printWidth: 80, tabWidth: 2, }; ================================================ FILE: turbo.json ================================================ { "$schema": "https://turbo.build/schema.json", "globalDependencies": [ "**/.env.*local" ], "tasks": { "topo": { "dependsOn": [ "^topo" ] }, "build": { "dependsOn": [ "^build", "typecheck" ], "outputs": [ "dist/**", ".next/**", "!.next/cache/**" ] }, "typecheck": { "dependsOn": [ "^topo" ], "outputs": [] }, "lint": { "dependsOn": [ "^topo" ] }, "format": { "dependsOn": [ "^topo" ] }, "lint:fix": { "dependsOn": [ "^topo" ] }, "format:fix": { "dependsOn": [ "^topo" ] }, "check-types": {}, "dev": { "cache": false, "persistent": true }, "clean": { "cache": false }, "release": { "cache": false } } }