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
An open-source Notion-style WYSIWYG editor with AI-powered autocompletions.
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:
[](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

## 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 (
Usage in dialog
Documentation
);
}
================================================
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 && (
)}
{isLoading && (
)}
{!isLoading && (
<>
addAIHighlight(editor)}
/>
{
if (completion)
return complete(completion, {
body: { option: "zap", command: inputValue },
}).then(() => setInputValue(""));
const slice = editor.state.selection.content();
const text = editor.storage.markdown.serializer.serialize(slice.content);
complete(text, {
body: { option: "zap", command: inputValue },
}).then(() => setInputValue(""));
}}
>
{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 && (
onOpenChange(true)}
size="sm"
>
Ask AI
{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 (
A
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"
>
))}
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"
>
{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 (
↗
Link
);
};
================================================
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 (
{
if (editor.isActive("math")) {
editor.chain().focus().unsetLatex().run();
} else {
const { from, to } = editor.state.selection;
const latex = editor.state.doc.textBetween(from, to);
if (!latex) return;
editor.chain().focus().setLatex({ latex }).run();
}
}}
>
);
};
================================================
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 (
{activeItem.name}
{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"
>
{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 }) => (
{
setFont(font);
}}
>
{currentFont === font && }
))}
*/}
Appearance
{appearances.map(({ theme, icon }) => (
{
setTheme(theme.toLowerCase());
}}
>
{currentTheme === theme.toLowerCase() && }
))}
);
}
================================================
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
}
}
}