Showing preview only (218K chars total). Download the full file or copy to clipboard to get everything.
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
================================================
<a href="https://novel.sh">
<img alt="Novel is a Notion-style WYSIWYG editor with AI-powered autocompletions." src="https://novel.sh/opengraph-image.png">
<h1 align="center">Novel</h1>
</a>
<p align="center">
An open-source Notion-style WYSIWYG editor with AI-powered autocompletions.
</p>
<p align="center">
<a href="https://news.ycombinator.com/item?id=36360789"><img src="https://img.shields.io/badge/Hacker%20News-369-%23FF6600" alt="Hacker News"></a>
<a href="https://github.com/steven-tey/novel/blob/main/LICENSE">
<img src="https://img.shields.io/github/license/steven-tey/novel?label=license&logo=github&color=f80&logoColor=fff" alt="License" />
</a>
<a href="https://github.com/steven-tey/novel"><img src="https://img.shields.io/github/stars/steven-tey/novel?style=social" alt="Novel.sh's GitHub repo"></a>
</p>
<p align="center">
<a href="#introduction"><strong>Introduction</strong></a> ·
<a href="#deploy-your-own"><strong>Deploy Your Own</strong></a> ·
<a href="#setting-up-locally"><strong>Setting Up Locally</strong></a> ·
<a href="#tech-stack"><strong>Tech Stack</strong></a> ·
<a href="#contributing"><strong>Contributing</strong></a> ·
<a href="#license"><strong>License</strong></a>
</p>
<br/>
## 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
<br />
## 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.
<a href="https://github.com/steven-tey/novel/graphs/contributors">
<img src="https://contrib.rocks/image?repo=steven-tey/novel" />
</a>
## 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<Response> {
// 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 (
<html lang="en" suppressHydrationWarning>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
================================================
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 (
<div className="flex min-h-screen flex-col items-center gap-4 py-4 sm:px-5">
<div className="flex w-full max-w-screen-lg items-center gap-2 px-4 sm:mb-[calc(20vh)]">
<Button size="icon" variant="outline">
<a href="https://github.com/steven-tey/novel" target="_blank" rel="noreferrer">
<GithubIcon />
</a>
</Button>
<Dialog>
<DialogTrigger asChild>
<Button className="ml gap-2">
<BookOpen className="h-4 w-4" />
Usage in dialog
</Button>
</DialogTrigger>
<DialogContent className="flex max-w-3xl h-[calc(100vh-24px)]">
<ScrollArea className="max-h-screen">
<TailwindAdvancedEditor />
</ScrollArea>
</DialogContent>
</Dialog>
<Link href="/docs" className="ml-auto">
<Button variant="ghost">Documentation</Button>
</Link>
<Menu />
</div>
<TailwindAdvancedEditor />
</div>
);
}
================================================
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<SetStateAction<string>>;
}>({
font: "Default",
setFont: () => {},
});
const ToasterProvider = () => {
const { theme } = useTheme() as {
theme: "light" | "dark" | "system";
};
return <Toaster theme={theme} />;
};
export default function Providers({ children }: { children: ReactNode }) {
const [font, setFont] = useLocalStorage<string>("novel__font", "Default");
return (
<ThemeProvider attribute="class" enableSystem disableTransitionOnChange defaultTheme="system">
<AppContext.Provider
value={{
font,
setFont,
}}
>
<ToasterProvider />
{children}
<Analytics />
</AppContext.Provider>
</ThemeProvider>
);
}
================================================
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 | JSONContent>(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 (
<div className="relative w-full max-w-screen-lg">
<div className="flex absolute right-5 top-5 z-10 mb-5 gap-2">
<div className="rounded-lg bg-accent px-2 py-1 text-sm text-muted-foreground">{saveStatus}</div>
<div className={charsCount ? "rounded-lg bg-accent px-2 py-1 text-sm text-muted-foreground" : "hidden"}>
{charsCount} Words
</div>
</div>
<EditorRoot>
<EditorContent
initialContent={initialContent}
extensions={extensions}
className="relative min-h-[500px] w-full max-w-screen-lg border-muted bg-background sm:mb-[calc(20vh)] sm:rounded-lg sm:border sm:shadow-lg"
editorProps={{
handleDOMEvents: {
keydown: (_view, event) => 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={<ImageResizer />}
>
<EditorCommand className="z-50 h-auto max-h-[330px] overflow-y-auto rounded-md border border-muted bg-background px-1 py-2 shadow-md transition-all">
<EditorCommandEmpty className="px-2 text-muted-foreground">No results</EditorCommandEmpty>
<EditorCommandList>
{suggestionItems.map((item) => (
<EditorCommandItem
value={item.title}
onCommand={(val) => 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}
>
<div className="flex h-10 w-10 items-center justify-center rounded-md border border-muted bg-background">
{item.icon}
</div>
<div>
<p className="font-medium">{item.title}</p>
<p className="text-xs text-muted-foreground">{item.description}</p>
</div>
</EditorCommandItem>
))}
</EditorCommandList>
</EditorCommand>
<GenerativeMenuSwitch open={openAI} onOpenChange={setOpenAI}>
<Separator orientation="vertical" />
<NodeSelector open={openNode} onOpenChange={setOpenNode} />
<Separator orientation="vertical" />
<LinkSelector open={openLink} onOpenChange={setOpenLink} />
<Separator orientation="vertical" />
<MathSelector />
<Separator orientation="vertical" />
<TextButtons />
<Separator orientation="vertical" />
<ColorSelector open={openColor} onOpenChange={setOpenColor} />
</GenerativeMenuSwitch>
</EditorContent>
</EditorRoot>
</div>
);
};
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 (
<>
<CommandGroup>
<CommandItem
className="gap-2 px-4"
value="replace"
onSelect={() => {
const selection = editor.view.state.selection;
editor
.chain()
.focus()
.insertContentAt(
{
from: selection.from,
to: selection.to,
},
completion,
)
.run();
}}
>
<Check className="h-4 w-4 text-muted-foreground" />
Replace selection
</CommandItem>
<CommandItem
className="gap-2 px-4"
value="insert"
onSelect={() => {
const selection = editor.view.state.selection;
editor
.chain()
.focus()
.insertContentAt(selection.to + 1, completion)
.run();
}}
>
<TextQuote className="h-4 w-4 text-muted-foreground" />
Insert below
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup>
<CommandItem onSelect={onDiscard} value="thrash" className="gap-2 px-4">
<TrashIcon className="h-4 w-4 text-muted-foreground" />
Discard
</CommandItem>
</CommandGroup>
</>
);
};
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 (
<>
<CommandGroup heading="Edit or review selection">
{options.map((option) => (
<CommandItem
onSelect={(value) => {
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.icon className="h-4 w-4 text-purple-500" />
{option.label}
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Use AI to do more">
<CommandItem
onSelect={() => {
const pos = editor.state.selection.from;
const text = getPrevText(editor, pos);
onSelect(text, "continue");
}}
value="continue"
className="gap-2 px-4"
>
<StepForward className="h-4 w-4 text-purple-500" />
Continue writing
</CommandItem>
</CommandGroup>
</>
);
};
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 (
<Command className="w-[350px]">
{hasCompletion && (
<div className="flex max-h-[400px]">
<ScrollArea>
<div className="prose p-2 px-4 prose-sm">
<Markdown>{completion}</Markdown>
</div>
</ScrollArea>
</div>
)}
{isLoading && (
<div className="flex h-12 w-full items-center px-4 text-sm font-medium text-muted-foreground text-purple-500">
<Magic className="mr-2 h-4 w-4 shrink-0 " />
AI is thinking
<div className="ml-2 mt-1">
<CrazySpinner />
</div>
</div>
)}
{!isLoading && (
<>
<div className="relative">
<CommandInput
value={inputValue}
onValueChange={setInputValue}
autoFocus
placeholder={hasCompletion ? "Tell AI what to do next" : "Ask AI to edit or generate..."}
onFocus={() => addAIHighlight(editor)}
/>
<Button
size="icon"
className="absolute right-2 top-1/2 h-6 w-6 -translate-y-1/2 rounded-full bg-purple-500 hover:bg-purple-900"
onClick={() => {
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(""));
}}
>
<ArrowUp className="h-4 w-4" />
</Button>
</div>
{hasCompletion ? (
<AICompletionCommands
onDiscard={() => {
editor.chain().unsetHighlight().focus().run();
onOpenChange(false);
}}
completion={completion}
/>
) : (
<AISelectorCommands onSelect={(value, option) => complete(value, { body: { option } })} />
)}
</>
)}
</Command>
);
}
================================================
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 (
<EditorBubble
tippyOptions={{
placement: open ? "bottom-start" : "top",
onHidden: () => {
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 && <AISelector open={open} onOpenChange={onOpenChange} />}
{!open && (
<Fragment>
<Button
className="gap-1 rounded-none text-purple-500"
variant="ghost"
onClick={() => onOpenChange(true)}
size="sm"
>
<Magic className="h-5 w-5" />
Ask AI
</Button>
{children}
</Fragment>
)}
</EditorBubble>
);
};
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 (
<Popover modal={true} open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button size="sm" className="gap-2 rounded-none" variant="ghost">
<span
className="rounded-sm px-1"
style={{
color: activeColorItem?.color,
backgroundColor: activeHighlightItem?.color,
}}
>
A
</span>
<ChevronDown className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent
sideOffset={5}
className="my-1 flex max-h-80 w-48 flex-col overflow-hidden overflow-y-auto rounded border p-1 shadow-xl "
align="start"
>
<div className="flex flex-col">
<div className="my-1 px-2 text-sm font-semibold text-muted-foreground">Color</div>
{TEXT_COLORS.map(({ name, color }) => (
<EditorBubbleItem
key={name}
onSelect={() => {
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"
>
<div className="flex items-center gap-2">
<div className="rounded-sm border px-2 py-px font-medium" style={{ color }}>
A
</div>
<span>{name}</span>
</div>
</EditorBubbleItem>
))}
</div>
<div>
<div className="my-1 px-2 text-sm font-semibold text-muted-foreground">Background</div>
{HIGHLIGHT_COLORS.map(({ name, color }) => (
<EditorBubbleItem
key={name}
onSelect={() => {
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"
>
<div className="flex items-center gap-2">
<div className="rounded-sm border px-2 py-px font-medium" style={{ backgroundColor: color }}>
A
</div>
<span>{name}</span>
</div>
{editor.isActive("highlight", { color }) && <Check className="h-4 w-4" />}
</EditorBubbleItem>
))}
</div>
</PopoverContent>
</Popover>
);
};
================================================
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<HTMLInputElement>(null);
const { editor } = useEditor();
// Autofocus on input by default
useEffect(() => {
inputRef.current?.focus();
});
if (!editor) return null;
return (
<Popover modal={true} open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button size="sm" variant="ghost" className="gap-2 rounded-none border-none">
<p className="text-base">↗</p>
<p
className={cn("underline decoration-stone-400 underline-offset-4", {
"text-blue-500": editor.isActive("link"),
})}
>
Link
</p>
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-60 p-0" sideOffset={10}>
<form
onSubmit={(e) => {
const target = e.currentTarget as HTMLFormElement;
e.preventDefault();
const input = target[0] as HTMLInputElement;
const url = getUrlFromString(input.value);
if (url) {
editor.chain().focus().setLink({ href: url }).run();
onOpenChange(false);
}
}}
className="flex p-1 "
>
<input
ref={inputRef}
type="text"
placeholder="Paste a link"
className="flex-1 bg-background p-1 text-sm outline-none"
defaultValue={editor.getAttributes("link").href || ""}
/>
{editor.getAttributes("link").href ? (
<Button
size="icon"
variant="outline"
type="button"
className="flex h-8 items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800"
onClick={() => {
editor.chain().focus().unsetLink().run();
inputRef.current.value = "";
onOpenChange(false);
}}
>
<Trash className="h-4 w-4" />
</Button>
) : (
<Button size="icon" className="h-8">
<Check className="h-4 w-4" />
</Button>
)}
</form>
</PopoverContent>
</Popover>
);
};
================================================
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 (
<Button
variant="ghost"
size="sm"
className="rounded-none w-12"
onClick={(evt) => {
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();
}
}}
>
<SigmaIcon
className={cn("size-4", { "text-blue-500": editor.isActive("math") })}
strokeWidth={2.3}
/>
</Button>
);
};
================================================
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<typeof useEditor>["editor"]) => void;
isActive: (editor: ReturnType<typeof useEditor>["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 (
<Popover modal={true} open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild className="gap-2 rounded-none border-none hover:bg-accent focus:ring-0">
<Button size="sm" variant="ghost" className="gap-2">
<span className="whitespace-nowrap text-sm">{activeItem.name}</span>
<ChevronDown className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent sideOffset={5} align="start" className="w-48 p-1">
{items.map((item) => (
<EditorBubbleItem
key={item.name}
onSelect={(editor) => {
item.command(editor);
onOpenChange(false);
}}
className="flex cursor-pointer items-center justify-between rounded-sm px-2 py-1 text-sm hover:bg-accent"
>
<div className="flex items-center space-x-2">
<div className="rounded-sm border p-1">
<item.icon className="h-3 w-3" />
</div>
<span>{item.name}</span>
</div>
{activeItem.name === item.name && <Check className="h-4 w-4" />}
</EditorBubbleItem>
))}
</PopoverContent>
</Popover>
);
};
================================================
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 (
<div className="flex">
{items.map((item) => (
<EditorBubbleItem
key={item.name}
onSelect={(editor) => {
item.command(editor);
}}
>
<Button size="sm" className="rounded-none" variant="ghost" type="button">
<item.icon
className={cn("h-4 w-4", {
"text-blue-500": item.isActive(editor),
})}
/>
</Button>
</EditorBubbleItem>
))}
</div>
);
};
================================================
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: <MessageSquarePlus size={18} />,
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: <Text size={18} />,
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: <CheckSquare size={18} />,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleTaskList().run();
},
},
{
title: "Heading 1",
description: "Big section heading.",
searchTerms: ["title", "big", "large"],
icon: <Heading1 size={18} />,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
},
},
{
title: "Heading 2",
description: "Medium section heading.",
searchTerms: ["subtitle", "medium"],
icon: <Heading2 size={18} />,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
},
},
{
title: "Heading 3",
description: "Small section heading.",
searchTerms: ["subtitle", "small"],
icon: <Heading3 size={18} />,
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: <List size={18} />,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleBulletList().run();
},
},
{
title: "Numbered List",
description: "Create a list with numbering.",
searchTerms: ["ordered"],
icon: <ListOrdered size={18} />,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
},
},
{
title: "Quote",
description: "Capture a quote.",
searchTerms: ["blockquote"],
icon: <TextQuote size={18} />,
command: ({ editor, range }) =>
editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").toggleBlockquote().run(),
},
{
title: "Code",
description: "Capture a code snippet.",
searchTerms: ["codeblock"],
icon: <Code size={18} />,
command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
{
title: "Image",
description: "Upload an image from your computer.",
searchTerms: ["photo", "picture", "media"],
icon: <ImageIcon size={18} />,
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: <Youtube size={18} />,
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: <Twitter size={18} />,
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<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
},
);
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<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-4" cmdk-input-wrapper="">
<Magic className="mr-2 h-4 w-4 shrink-0 text-purple-500 " />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />);
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />;
};
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<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
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 (
<div className="flex items-center justify-center gap-0.5">
<div className="h-1.5 w-1.5 animate-bounce rounded-full bg-purple-500 [animation-delay:-0.3s]" />
<div className="h-1.5 w-1.5 animate-bounce rounded-full bg-purple-500 [animation-delay:-0.15s]" />
<div className="h-1.5 w-1.5 animate-bounce rounded-full bg-purple-500" />
</div>
);
};
export default CrazySpinner;
================================================
FILE: apps/web/components/tailwind/ui/icons/font-default.tsx
================================================
export default function FontDefault({ className }: { className?: string }) {
return (
<svg width="27" height="17" viewBox="0 0 27 17" xmlns="http://www.w3.org/2000/svg" className={className}>
<title>Font Default Icon</title>
<path
d="M2.67735 16.7224H0L6.01587 0H8.92994L14.9458 16.7224H12.2685L7.54229 3.03746H7.41169L2.67735 16.7224ZM3.12629 10.1739H11.8114V12.2968H3.12629V10.1739Z"
fill="currentColor"
/>
<path
d="M20.8127 17C20.0182 17 19.2999 16.853 18.6578 16.5591C18.0156 16.2597 17.5068 15.8269 17.1314 15.2608C16.7613 14.6947 16.5763 14.0006 16.5763 13.1787C16.5763 12.471 16.7123 11.8886 16.9844 11.4313C17.2565 10.9741 17.6238 10.6121 18.0864 10.3453C18.5489 10.0786 19.0659 9.8772 19.6373 9.74111C20.2087 9.60503 20.7909 9.5016 21.3841 9.43084C22.1351 9.34374 22.7445 9.27298 23.2125 9.21854C23.6805 9.15866 24.0206 9.0634 24.2329 8.93276C24.4451 8.80211 24.5512 8.58982 24.5512 8.29587V8.23871C24.5512 7.52562 24.3499 6.9731 23.9472 6.58117C23.5499 6.18924 22.9568 5.99328 22.1677 5.99328C21.346 5.99328 20.6984 6.17563 20.225 6.54035C19.757 6.89962 19.4332 7.29971 19.2536 7.74063L16.9599 7.21806C17.232 6.45597 17.6293 5.84086 18.1517 5.37272C18.6795 4.89914 19.2863 4.5562 19.972 4.3439C20.6576 4.12616 21.3787 4.01729 22.1351 4.01729C22.6357 4.01729 23.1663 4.07717 23.7268 4.19693C24.2927 4.31124 24.8206 4.52354 25.3103 4.83381C25.8055 5.14409 26.2109 5.58774 26.5266 6.16475C26.8422 6.73631 27 7.47935 27 8.39385V16.7224H24.6165V15.0077H24.5186C24.3607 15.3234 24.124 15.6337 23.8084 15.9385C23.4928 16.2434 23.0874 16.4965 22.5922 16.6979C22.097 16.8993 21.5038 17 20.8127 17ZM21.3433 15.0403C22.0181 15.0403 22.5949 14.907 23.0738 14.6403C23.5581 14.3735 23.9254 14.0251 24.1757 13.5951C24.4315 13.1596 24.5594 12.6942 24.5594 12.1988V10.5821C24.4723 10.6692 24.3036 10.7509 24.0533 10.8271C23.8084 10.8979 23.5282 10.9605 23.2125 11.0149C22.8969 11.0639 22.5894 11.1102 22.2902 11.1537C21.9909 11.1918 21.7405 11.2245 21.5392 11.2517C21.0658 11.3116 20.6331 11.4123 20.2413 11.5538C19.855 11.6953 19.5448 11.8995 19.3108 12.1662C19.0822 12.4275 18.968 12.7759 18.968 13.2113C18.968 13.8156 19.1911 14.2728 19.6373 14.5831C20.0835 14.8879 20.6522 15.0403 21.3433 15.0403Z"
fill="currentColor"
/>
</svg>
);
}
================================================
FILE: apps/web/components/tailwind/ui/icons/font-mono.tsx
================================================
export default function FontMono({ className }: { className?: string }) {
return (
<svg
width="28"
height="19"
viewBox="0 0 28 19"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<title>Font Mono Icon</title>
<path
d="M10.4513 13.9906H3.35401L3.80321 11.8861H9.94225L10.4513 13.9906ZM7.06738 5.72075L2.90481 18.6739H0L6.4984 0H7.66631L14.1647 18.6739H11.1701L7.06738 5.72075Z"
fill="currentColor"
/>
<path
d="M20.6032 19C19.066 19 17.8681 18.6542 17.0096 17.9626C16.1711 17.2512 15.7519 16.3323 15.7519 15.2059C15.7519 14.455 15.9216 13.7832 16.261 13.1903C16.6203 12.5777 17.1194 12.0541 17.7583 11.6193C18.4171 11.1846 19.2057 10.8487 20.1241 10.6115C21.0424 10.3547 22.0806 10.2262 23.2385 10.2262C23.4781 10.2262 23.7276 10.2361 23.9872 10.2559C24.2667 10.2559 24.5562 10.2657 24.8556 10.2855C25.175 10.3053 25.4945 10.325 25.8139 10.3448L25.9037 12.4493C25.6242 12.4098 25.3248 12.3801 25.0053 12.3604C24.7059 12.3406 24.4064 12.3307 24.107 12.3307C23.8075 12.3307 23.528 12.3307 23.2685 12.3307C22.4898 12.3307 21.8111 12.39 21.2321 12.5086C20.6731 12.6074 20.2039 12.7655 19.8246 12.9828C19.4652 13.2002 19.1957 13.467 19.016 13.7832C18.8364 14.0993 18.7465 14.4649 18.7465 14.8799C18.7465 15.2158 18.8064 15.5122 18.9262 15.7691C19.046 16.0062 19.2057 16.194 19.4053 16.3323C19.625 16.4706 19.8745 16.5793 20.154 16.6583C20.4535 16.7176 20.7729 16.7473 21.1123 16.7473C21.7711 16.7473 22.3501 16.6386 22.8492 16.4212C23.3483 16.2038 23.7676 15.8877 24.107 15.4727C24.4663 15.038 24.7358 14.5044 24.9155 13.8721C25.0952 13.22 25.185 12.4592 25.185 11.5897C25.185 10.3843 25.0453 9.44566 24.7658 8.77379C24.5062 8.10192 24.1169 7.63755 23.5979 7.38066C23.0788 7.12376 22.4299 6.99532 21.6513 6.99532C21.0125 6.99532 20.3936 7.104 19.7947 7.32137C19.2157 7.51898 18.6467 7.89444 18.0877 8.44774L16.5005 6.66927C17.2592 5.95788 18.0877 5.4441 18.9861 5.12793C19.9045 4.81175 20.8428 4.65367 21.8011 4.65367C22.6795 4.65367 23.4881 4.76235 24.2267 4.97972C24.9854 5.17733 25.6442 5.51326 26.2032 5.98752C26.7822 6.46178 27.2214 7.104 27.5209 7.9142C27.8403 8.72439 28 9.75195 28 10.9969V18.6739H24.8856V16.9844C24.626 17.3796 24.3365 17.7155 24.0171 17.9922C23.6977 18.2491 23.3483 18.4566 22.969 18.6147C22.6096 18.753 22.2303 18.8518 21.831 18.9111C21.4317 18.9704 21.0225 19 20.6032 19Z"
fill="currentColor"
/>
</svg>
);
}
================================================
FILE: apps/web/components/tailwind/ui/icons/font-serif.tsx
================================================
export default function FontSerif({ className }: { className?: string }) {
return (
<svg
width="31"
height="18"
viewBox="0 0 31 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<title>Font Serif Icon</title>
<path
d="M0.108486 17.8929C0.0361621 17.8214 0 17.6875 0 17.4911C0 17.1696 0.0180811 17.0089 0.0542432 17.0089C0.198892 17.0089 0.406824 16.9732 0.67804 16.9018C0.967337 16.8304 1.23855 16.7411 1.49169 16.6339C1.7629 16.5089 1.93467 16.3839 2.007 16.2589C2.18781 15.9911 2.35958 15.6875 2.52231 15.3482C2.68504 14.9911 2.85681 14.5982 3.03762 14.1696L8.08224 2.11607C8.51619 1.02679 8.85069 0.383929 9.08574 0.1875C9.21231 0.0625 9.366 0 9.54681 0C9.67337 0 9.78186 0.0446429 9.87227 0.133929L15.5678 14.1696C15.7125 14.5089 15.8481 14.8482 15.9746 15.1875C16.1193 15.5089 16.2549 15.8214 16.3815 16.125C16.4538 16.3036 16.6165 16.4643 16.8696 16.6071C17.1228 16.7321 17.385 16.8304 17.6562 16.9018C17.9274 16.9732 18.1353 17.0089 18.28 17.0089C18.3523 17.0089 18.3885 17.1696 18.3885 17.4911C18.3885 17.6875 18.3704 17.8214 18.3342 17.8929L14.9169 17.7321L11.3911 17.8929C11.3188 17.8214 11.2826 17.6518 11.2826 17.3839C11.2826 17.1339 11.3188 17.0089 11.3911 17.0089C11.608 17.0089 11.9064 16.9554 12.2861 16.8482C12.6658 16.7411 12.8918 16.6161 12.9641 16.4732C13.0184 16.3661 13.0455 16.25 13.0455 16.125C13.0455 15.9464 13.0003 15.7054 12.9099 15.4018C12.8195 15.0982 12.6929 14.7232 12.5302 14.2768L11.5809 11.7857C11.5086 11.7143 11.4453 11.6786 11.3911 11.6786C11.1018 11.6607 10.7582 11.6518 10.3605 11.6518C9.98075 11.6339 9.54681 11.625 9.05862 11.625C8.08224 11.625 7.05162 11.6518 5.96675 11.7054C5.85827 11.7054 5.80402 11.7411 5.80402 11.8125L4.7734 14.3036C4.59259 14.75 4.45698 15.125 4.36658 15.4286C4.29425 15.7143 4.25809 15.9196 4.25809 16.0446C4.25809 16.2589 4.35754 16.4375 4.55643 16.5804C4.66492 16.6518 4.82765 16.7232 5.04462 16.7946C5.27967 16.8482 5.51473 16.9018 5.74978 16.9554C5.98484 16.9911 6.14756 17.0089 6.23797 17.0089C6.29221 17.0268 6.31933 17.1518 6.31933 17.3839C6.31933 17.5089 6.31029 17.6161 6.29221 17.7054C6.29221 17.7768 6.28317 17.8393 6.26509 17.8929C5.59609 17.8571 5.04462 17.8214 4.61067 17.7857C4.19481 17.75 3.80607 17.7321 3.44444 17.7321C3.08282 17.7321 2.64888 17.7411 2.14261 17.7589C1.63634 17.7768 0.958297 17.8214 0.108486 17.8929ZM8.57043 10.1786C9.22135 10.1786 9.96267 10.1518 10.7944 10.0982L10.8486 9.99107L8.7874 4.63393L6.56343 9.99107C6.56343 10.0625 6.58151 10.0982 6.61767 10.0982C7.25051 10.1518 7.90143 10.1786 8.57043 10.1786Z"
fill="currentColor"
/>
<path
d="M23.0534 18C22.1493 18 21.408 17.7589 20.8294 17.2768C20.2689 16.7768 19.9886 16.0982 19.9886 15.2411C19.9886 14.6518 20.1785 14.1161 20.5582 13.6339C20.956 13.1518 21.5074 12.7411 22.2126 12.4018C22.4657 12.2768 22.6917 12.1786 22.8906 12.1071C23.1076 12.0179 23.3065 11.9375 23.4873 11.8661C24.1202 11.6696 24.6445 11.5089 25.0604 11.3839C25.4943 11.2589 25.8017 11.1696 25.9825 11.1161C26.1452 11.0268 26.2266 10.8839 26.2266 10.6875V9.21429C26.2266 8.58929 26.0819 8.09821 25.7927 7.74107C25.5034 7.38393 25.1146 7.20536 24.6264 7.20536C24.084 7.20536 23.6591 7.375 23.3517 7.71429C23.0443 8.03571 22.8906 8.5 22.8906 9.10714C22.8906 9.46429 22.755 9.73214 22.4838 9.91071C22.2126 10.0714 21.9052 10.1518 21.5617 10.1518C20.8927 10.1518 20.4226 9.9375 20.1514 9.50893C20.1514 9.08036 20.3141 8.66071 20.6395 8.25C20.965 7.82143 21.3899 7.4375 21.9143 7.09821C22.4567 6.75893 23.0534 6.49107 23.7043 6.29464C24.3552 6.08036 25.0061 5.97321 25.657 5.97321C26.7781 5.97321 27.6188 6.26786 28.1794 6.85714C28.7399 7.42857 29.0201 8.41964 29.0201 9.83036V15.1071C29.0201 15.8393 29.3185 16.2054 29.9151 16.2054C30.1864 16.2054 30.4937 16.0893 30.8373 15.8571C30.9458 15.875 31 15.9732 31 16.1518C31 16.4911 30.9458 16.7589 30.8373 16.9554C30.5661 17.1696 30.2044 17.3929 29.7524 17.625C29.3004 17.8571 28.8664 17.9732 28.4506 17.9732C28.0347 17.9732 27.6279 17.8214 27.2301 17.5179C26.8323 17.2143 26.5701 16.8839 26.4436 16.5268C26.2989 16.6518 26.1904 16.7411 26.1181 16.7946C26.0458 16.8482 25.9283 16.9375 25.7655 17.0625C25.3858 17.3125 24.97 17.5357 24.5179 17.7321C24.0659 17.9107 23.5777 18 23.0534 18ZM24.4366 16.2589C24.9248 16.2589 25.3406 16.1339 25.6842 15.8839C26.0458 15.6339 26.2266 15.3482 26.2266 15.0268V12.2946L24.4908 12.8839C24.0749 13.0625 23.7043 13.2768 23.3788 13.5268C23.0534 13.7768 22.8906 14.1339 22.8906 14.5982C22.8906 15.1696 23.0353 15.5893 23.3246 15.8571C23.6139 16.125 23.9845 16.2589 24.4366 16.2589Z"
fill="currentColor"
/>
</svg>
);
}
================================================
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 (
<svg
aria-hidden="true"
className={`${dimensions || "h-4 w-4"} animate-spin fill-stone-600 text-stone-200`}
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
);
}
================================================
FILE: apps/web/components/tailwind/ui/icons/magic.tsx
================================================
export default function Magic({ className }: { className: string }) {
return (
<svg
width="469"
height="469"
viewBox="0 0 469 469"
fill="none"
xmlns="http://www.w3.org/2000/svg"
shapeRendering="geometricPrecision"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
className={className}
>
<title>Magic AI icon</title>
<path
d="M237.092 62.3004L266.754 71.4198C267.156 71.5285 267.51 71.765 267.765 72.0934C268.02 72.4218 268.161 72.8243 268.166 73.2399C268.172 73.6555 268.042 74.0616 267.796 74.3967C267.55 74.7318 267.201 74.9777 266.803 75.097L237.141 84.3145C236.84 84.4058 236.566 84.5699 236.344 84.7922C236.121 85.0146 235.957 85.2883 235.866 85.5893L226.747 115.252C226.638 115.653 226.401 116.008 226.073 116.263C225.745 116.517 225.342 116.658 224.926 116.664C224.511 116.669 224.105 116.539 223.77 116.293C223.435 116.047 223.189 115.699 223.069 115.301L213.852 85.6383C213.761 85.3374 213.597 85.0636 213.374 84.8412C213.152 84.6189 212.878 84.4548 212.577 84.3635L182.914 75.2441C182.513 75.1354 182.158 74.8989 181.904 74.5705C181.649 74.2421 181.508 73.8396 181.503 73.424C181.497 73.0084 181.627 72.6023 181.873 72.2672C182.119 71.9321 182.467 71.6863 182.865 71.5669L212.528 62.3494C212.829 62.2582 213.103 62.0941 213.325 61.8717C213.547 61.6494 213.712 61.3756 213.803 61.0747L222.922 31.4121C223.031 31.0109 223.267 30.656 223.596 30.4013C223.924 30.1465 224.327 30.0057 224.742 30.0002C225.158 29.9946 225.564 30.1247 225.899 30.3706C226.234 30.6165 226.48 30.9649 226.599 31.363L235.817 61.0257C235.908 61.3266 236.072 61.6003 236.295 61.8227C236.517 62.0451 236.791 62.2091 237.092 62.3004Z"
fill="currentColor"
/>
<path
d="M155.948 155.848L202.771 168.939C203.449 169.131 204.045 169.539 204.47 170.101C204.895 170.663 205.125 171.348 205.125 172.052C205.125 172.757 204.895 173.442 204.47 174.004C204.045 174.566 203.449 174.974 202.771 175.166L155.899 188.06C155.361 188.209 154.87 188.496 154.475 188.891C154.079 189.286 153.793 189.777 153.644 190.316L140.553 237.138C140.361 237.816 139.953 238.413 139.391 238.838C138.829 239.262 138.144 239.492 137.44 239.492C136.735 239.492 136.05 239.262 135.488 238.838C134.927 238.413 134.519 237.816 134.327 237.138L121.432 190.267C121.283 189.728 120.997 189.237 120.601 188.842C120.206 188.446 119.715 188.16 119.177 188.011L72.3537 174.92C71.676 174.728 71.0795 174.32 70.6547 173.759C70.2299 173.197 70 172.512 70 171.807C70 171.103 70.2299 170.418 70.6547 169.856C71.0795 169.294 71.676 168.886 72.3537 168.694L119.226 155.799C119.764 155.65 120.255 155.364 120.65 154.969C121.046 154.573 121.332 154.082 121.481 153.544L134.572 106.721C134.764 106.043 135.172 105.447 135.734 105.022C136.295 104.597 136.981 104.367 137.685 104.367C138.389 104.367 139.075 104.597 139.637 105.022C140.198 105.447 140.606 106.043 140.798 106.721L153.693 153.593C153.842 154.131 154.128 154.622 154.524 155.018C154.919 155.413 155.41 155.699 155.948 155.848Z"
fill="currentColor"
/>
<path
d="M386.827 289.992C404.33 292.149 403.84 305.828 386.876 307.299C346.623 310.829 298.869 316.271 282.199 360.005C274.844 379.192 269.942 403.2 267.49 432.029C267.427 432.846 267.211 433.626 266.856 434.319C266.501 435.012 266.015 435.602 265.431 436.05C254.988 444.041 251.212 434.186 250.183 425.606C239.2 332.353 214.588 316.909 124.668 306.122C123.892 306.031 123.151 305.767 122.504 305.35C121.857 304.933 121.322 304.375 120.942 303.72C116.399 295.679 119.324 291.038 129.718 289.796C224.688 278.47 236.062 262.83 250.183 169.331C252.177 156.355 257.259 154.083 265.431 162.516C266.51 163.593 267.202 165.099 267.392 166.782C279.257 258.564 293.328 278.617 386.827 289.992Z"
fill="currentColor"
/>
</svg>
);
}
================================================
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: <FontDefault className="h-4 w-4" />,
// },
// {
// font: "Serif",
// icon: <FontSerif className="h-4 w-4" />,
// },
// {
// font: "Mono",
// icon: <FontMono className="h-4 w-4" />,
// },
// ];
const appearances = [
{
theme: "System",
icon: <Monitor className="h-4 w-4" />,
},
{
theme: "Light",
icon: <SunDim className="h-4 w-4" />,
},
{
theme: "Dark",
icon: <Moon className="h-4 w-4" />,
},
];
export default function Menu() {
// const { font: currentFont, setFont } = useContext(AppContext);
const { theme: currentTheme, setTheme } = useTheme();
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon">
<MenuIcon width={16} />
</Button>
</PopoverTrigger>
<PopoverContent className="w-52 p-2" align="end">
{/* <div className="p-2">
<p className="p-2 text-xs font-medium text-stone-500">Font</p>
{fonts.map(({ font, icon }) => (
<button
key={font}
className="flex w-full items-center justify-between rounded px-2 py-1 text-sm text-stone-600 hover:bg-stone-100"
onClick={() => {
setFont(font);
}}
>
<div className="flex items-center space-x-2">
<div className="rounded-sm border border-stone-200 p-1">
{icon}
</div>
<span>{font}</span>
</div>
{currentFont === font && <Check className="h-4 w-4" />}
</button>
))}
</div> */}
<p className="p-2 text-xs font-medium text-muted-foreground">Appearance</p>
{appearances.map(({ theme, icon }) => (
<Button
variant="ghost"
key={theme}
className="flex w-full items-center justify-between rounded px-2 py-1.5 text-sm"
onClick={() => {
setTheme(theme.toLowerCase());
}}
>
<div className="flex items-center space-x-2">
<div className="rounded-sm border p-1">{icon}</div>
<span>{theme}</span>
</div>
{currentTheme === theme.toLowerCase() && <Check className="h-4 w-4" />}
</Button>
))}
</PopoverContent>
</Popover>
);
}
================================================
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<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
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<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
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<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn("shrink-0 bg-border", orientation === "horizontal" ? "h-[1px] w-full" : " w-[1px]", className)}
{...props}
/>
));
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 = <T>(
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 <Editor />\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 <stevensteel97@gmail.com>",
"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<ComponentPropsWithoutRef<"div">, "onSelect">
>(({ children, asChild, onSelect, ...rest }, ref) => {
const { editor } = useCurrentEditor();
const Comp = asChild ? Slot : "div";
if (!editor) return null;
return (
<Comp ref={ref} {...rest} onClick={() => onSelect?.(editor)}>
{children}
</Comp>
);
});
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<BubbleMenuProps, "editor"> {
readonly children: ReactNode;
}
export const EditorBubble = forwardRef<HTMLDivElement, EditorBubbleProps>(
({ children, tippyOptions, ...rest }, ref) => {
const { editor: currentEditor } = useCurrentEditor();
const instanceRef = useRef<Instance<Props> | null>(null);
useEffect(() => {
if (!instanceRef.current || !tippyOptions?.placement) return;
instanceRef.current.setProps({ placement: tippyOptions.placement });
instanceRef.current.popperInstance?.update();
}, [tippyOptions?.placement]);
const bubbleMenuProps: Omit<BubbleMenuProps, "children"> = 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
<div ref={ref}>
<BubbleMenu {...bubbleMenuProps}>{children}</BubbleMenu>
</div>
);
},
);
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<typeof CommandItem>
>(({ children, onCommand, ...rest }, ref) => {
const { editor } = useCurrentEditor();
const range = useAtomValue(rangeAtom);
if (!editor || !range) return null;
return (
<CommandItem ref={ref} {...rest} onSelect={() => onCommand({ editor, range })}>
{children}
</CommandItem>
);
});
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<typeof tunnel>);
interface EditorCommandOutProps {
readonly query: string;
readonly range: Range;
}
export const EditorCommandOut: FC<EditorCommandOutProps> = ({ 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 (
<EditorCommandTunnelContext.Consumer>
{(tunnelInstance) => <tunnelInstance.Out />}
</EditorCommandTunnelContext.Consumer>
);
};
export const EditorCommand = forwardRef<HTMLDivElement, ComponentPropsWithoutRef<typeof Command>>(
({ children, className, ...rest }, ref) => {
const [query, setQuery] = useAtom(queryAtom);
return (
<EditorCommandTunnelContext.Consumer>
{(tunnelInstance) => (
<tunnelInstance.In>
<Command
ref={ref}
onKeyDown={(e) => {
e.stopPropagation();
}}
id="slash-command"
className={className}
{...rest}
>
<Command.Input value={query} onValueChange={setQuery} style={{ display: "none" }} />
{children}
</Command>
</tunnelInstance.In>
)}
</EditorCommandTunnelContext.Consumer>
);
},
);
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<EditorRootProps> = ({ children }) => {
const tunnelInstance = useRef(tunnel()).current;
return (
<Provider store={novelStore}>
<EditorCommandTunnelContext.Provider value={tunnelInstance}>{children}</EditorCommandTunnelContext.Provider>
</Provider>
);
};
export type EditorContentProps = Omit<EditorProviderProps, "content"> & {
readonly children?: ReactNode;
readonly className?: string;
readonly initialContent?: JSONContent;
};
export const EditorContent = forwardRef<HTMLDivElement, EditorContentProps>(
({ className, children, initialContent, ...rest }, ref) => (
<div ref={ref} className={className}>
<EditorProvider {...rest} content={initialContent}>
{children}
</EditorProvider>
</div>
),
);
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<string, string>;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
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<AIHighlightOptions>({
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<ReturnType> {
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 (
<Moveable
target={document.querySelector(".ProseMirror-selectednode") as HTMLDivElement}
container={null}
origin={false}
/* Resize event edges */
edge={false}
throttleDrag={0}
/* When resize or scale, keeps a ratio of the width, height. */
keepRatio={true}
/* resizable*/
/* Only one of resizable, scalable, warpable can be used. */
resizable={true}
throttleResize={0}
onResize={({
target,
width,
height,
// dist,
delta,
}) => {
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<string, any>;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
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<MathematicsOptions>({
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<Element> | null) => {
let component: ReactRenderer | null = null;
let popup: Instance<Props>[] | 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<ReactNodeViewRendererOptions> }) => {
const url = (node?.attrs as Record<string, string>)?.src;
const tweetId = url?.split("/").pop();
if (!tweetId) {
return null;
}
return (
<NodeViewWrapper>
<div data-twitter="">
<Tweet id={tweetId} />
</div>
</NodeViewWrapper>
);
};
export interface TwitterOptions {
/**
* Controls if the paste handler for tweets should be added.
* @default true
* @example false
*/
addPasteHandler: boolean;
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
HTMLAttributes: Record<string, any>;
/**
* 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<ReturnType> {
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<TwitterOptions>({
name: "twitter",
addOptions() {
return {
addPasteHandler
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
SYMBOL INDEX (84 symbols across 35 files)
FILE: apps/web/app/api/generate/route.ts
function POST (line 10) | async function POST(req: Request): Promise<Response> {
FILE: apps/web/app/api/upload/route.ts
function POST (line 6) | async function POST(req: Request) {
FILE: apps/web/app/layout.tsx
function RootLayout (line 33) | function RootLayout({ children }: { children: ReactNode }) {
FILE: apps/web/app/page.tsx
function Page (line 9) | function Page() {
FILE: apps/web/app/providers.tsx
function Providers (line 24) | function Providers({ children }: { children: ReactNode }) {
FILE: apps/web/components/tailwind/extensions.ts
method addProseMirrorPlugins (line 42) | addProseMirrorPlugins() {
FILE: apps/web/components/tailwind/generative/ai-selector-commands.tsx
type AISelectorCommandsProps (line 28) | interface AISelectorCommandsProps {
FILE: apps/web/components/tailwind/generative/ai-selector.tsx
type AISelectorProps (line 20) | interface AISelectorProps {
function AISelector (line 25) | function AISelector({ onOpenChange }: AISelectorProps) {
FILE: apps/web/components/tailwind/generative/generative-menu-switch.tsx
type GenerativeMenuSwitchProps (line 7) | interface GenerativeMenuSwitchProps {
FILE: apps/web/components/tailwind/selectors/color-selector.tsx
type BubbleColorMenuItem (line 6) | interface BubbleColorMenuItem {
constant TEXT_COLORS (line 11) | const TEXT_COLORS: BubbleColorMenuItem[] = [
constant HIGHLIGHT_COLORS (line 50) | const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
type ColorSelectorProps (line 89) | interface ColorSelectorProps {
FILE: apps/web/components/tailwind/selectors/link-selector.tsx
function isValidUrl (line 9) | function isValidUrl(url: string) {
function getUrlFromString (line 17) | function getUrlFromString(str: string) {
type LinkSelectorProps (line 27) | interface LinkSelectorProps {
FILE: apps/web/components/tailwind/selectors/node-selector.tsx
type SelectorItem (line 20) | type SelectorItem = {
type NodeSelectorProps (line 85) | interface NodeSelectorProps {
FILE: apps/web/components/tailwind/ui/button.tsx
type ButtonProps (line 33) | interface ButtonProps
FILE: apps/web/components/tailwind/ui/command.tsx
type CommandDialogProps (line 26) | interface CommandDialogProps extends DialogProps {}
FILE: apps/web/components/tailwind/ui/icons/font-default.tsx
function FontDefault (line 1) | function FontDefault({ className }: { className?: string }) {
FILE: apps/web/components/tailwind/ui/icons/font-mono.tsx
function FontMono (line 1) | function FontMono({ className }: { className?: string }) {
FILE: apps/web/components/tailwind/ui/icons/font-serif.tsx
function FontSerif (line 1) | function FontSerif({ className }: { className?: string }) {
FILE: apps/web/components/tailwind/ui/icons/loading-circle.tsx
function LoadingCircle (line 1) | function LoadingCircle({ dimensions }: { dimensions?: string }) {
FILE: apps/web/components/tailwind/ui/icons/magic.tsx
function Magic (line 1) | function Magic({ className }: { className: string }) {
FILE: apps/web/components/tailwind/ui/menu.tsx
function Menu (line 37) | function Menu() {
FILE: apps/web/lib/utils.ts
function cn (line 4) | function cn(...inputs: ClassValue[]) {
FILE: packages/headless/src/components/editor-bubble-item.tsx
type EditorBubbleItemProps (line 7) | interface EditorBubbleItemProps {
FILE: packages/headless/src/components/editor-bubble.tsx
type EditorBubbleProps (line 7) | interface EditorBubbleProps extends Omit<BubbleMenuProps, "editor"> {
FILE: packages/headless/src/components/editor-command-item.tsx
type EditorCommandItemProps (line 9) | interface EditorCommandItemProps {
FILE: packages/headless/src/components/editor-command.tsx
type EditorCommandOutProps (line 12) | interface EditorCommandOutProps {
FILE: packages/headless/src/components/editor.tsx
type EditorProps (line 10) | interface EditorProps {
type EditorRootProps (line 15) | interface EditorRootProps {
type EditorContentProps (line 29) | type EditorContentProps = Omit<EditorProviderProps, "content"> & {
FILE: packages/headless/src/extensions/ai-highlight.ts
type AIHighlightOptions (line 3) | interface AIHighlightOptions {
type Commands (line 8) | interface Commands<ReturnType> {
method addOptions (line 32) | addOptions() {
method addAttributes (line 38) | addAttributes() {
method parseHTML (line 57) | parseHTML() {
method renderHTML (line 65) | renderHTML({ HTMLAttributes }) {
method addCommands (line 69) | addCommands() {
method addKeyboardShortcuts (line 89) | addKeyboardShortcuts() {
method addInputRules (line 95) | addInputRules() {
method addPasteRules (line 104) | addPasteRules() {
FILE: packages/headless/src/extensions/custom-keymap.ts
type Commands (line 5) | interface Commands<ReturnType> {
method addCommands (line 18) | addCommands() {
method addKeyboardShortcuts (line 35) | addKeyboardShortcuts() {
FILE: packages/headless/src/extensions/index.ts
method addInputRules (line 45) | addInputRules() {
FILE: packages/headless/src/extensions/mathematics.ts
type MathematicsOptions (line 5) | interface MathematicsOptions {
type Commands (line 23) | interface Commands<ReturnType> {
method addAttributes (line 55) | addAttributes() {
method addOptions (line 61) | addOptions() {
method addCommands (line 79) | addCommands() {
method parseHTML (line 130) | parseHTML() {
method renderHTML (line 134) | renderHTML({ node, HTMLAttributes }) {
method renderText (line 145) | renderText({ node }) {
method addNodeView (line 149) | addNodeView() {
FILE: packages/headless/src/extensions/slash-command.tsx
method addOptions (line 12) | addOptions() {
method addProseMirrorPlugins (line 22) | addProseMirrorPlugins() {
type SuggestionItem (line 88) | interface SuggestionItem {
FILE: packages/headless/src/extensions/twitter.tsx
constant TWITTER_REGEX_GLOBAL (line 4) | const TWITTER_REGEX_GLOBAL = /(https?:\/\/)?(www\.)?x\.com\/([a-zA-Z0-9_...
constant TWITTER_REGEX (line 5) | const TWITTER_REGEX = /^https?:\/\/(www\.)?x\.com\/([a-zA-Z0-9_]{1,15})(...
type TwitterOptions (line 28) | interface TwitterOptions {
type SetTweetOptions (line 57) | type SetTweetOptions = { src: string };
type Commands (line 60) | interface Commands<ReturnType> {
method addOptions (line 78) | addOptions() {
method addNodeView (line 87) | addNodeView() {
method inline (line 91) | inline() {
method group (line 95) | group() {
method addAttributes (line 101) | addAttributes() {
method parseHTML (line 109) | parseHTML() {
method addCommands (line 117) | addCommands() {
method addPasteRules (line 134) | addPasteRules() {
method renderHTML (line 150) | renderHTML({ HTMLAttributes }) {
FILE: packages/headless/src/extensions/updated-image.ts
method addAttributes (line 5) | addAttributes() {
FILE: packages/headless/src/plugins/upload-images.tsx
method init (line 10) | init() {
method apply (line 13) | apply(tr, set) {
method decorations (line 39) | decorations(state) {
function findPlaceholder (line 46) | function findPlaceholder(state: EditorState, id: {}) {
type ImageUploadOptions (line 53) | interface ImageUploadOptions {
type UploadFn (line 114) | type UploadFn = (file: File, view: EditorView, pos: number) => void;
FILE: packages/headless/src/utils/index.ts
function isValidUrl (line 4) | function isValidUrl(url: string) {
function getUrlFromString (line 13) | function getUrlFromString(str: string) {
Condensed preview — 95 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (217K chars).
[
{
"path": ".changeset/config.json",
"chars": 397,
"preview": "{\n \"$schema\": \"https://unpkg.com/@changesets/config@3.0.0/schema.json\",\n \"changelog\": [\n \"@changesets/chang"
},
{
"path": ".github/FUNDING.yml",
"chars": 66,
"preview": "# These are supported funding model platforms\n\ngithub: andrewdoro\n"
},
{
"path": ".github/ISSUE_TEMPLATE/bug_report.yml",
"chars": 1236,
"preview": "name: 🐞 Bug Report\ndescription: Create a bug report to help us improve\ntitle: \"bug: \"\nlabels: [\"🐞❔ unconfirmed bug\"]\nbod"
},
{
"path": ".github/ISSUE_TEMPLATE/feature_request.yml",
"chars": 1203,
"preview": "# This template is heavily inspired by the Next.js's template:\n# See here: https://github.com/vercel/next.js/tree/canary"
},
{
"path": ".github/workflows/release.yaml",
"chars": 2064,
"preview": "# This workflow will release the packages with Changesets\n\nname: 🚀 Release\n\non:\n push:\n branches:\n - main\n wor"
},
{
"path": ".gitignore",
"chars": 485,
"preview": "# dependencies\n/node_modules\n/.pnp\n.pnp.js\n\nnode_modules\npackages/*/node_modules\napps/*/node_modules\n.next\n\n# next.js\n/."
},
{
"path": ".husky/commit-msg",
"chars": 26,
"preview": "pnpm commitlint --edit $1\n"
},
{
"path": ".vscode/extensions.json",
"chars": 130,
"preview": "{\n \"recommendations\": [\n \"yoavbls.pretty-ts-errors\",\n \"bradlc.vscode-tailwindcss\",\n \"biomejs.biome\"\n "
},
{
"path": ".vscode/settings.json",
"chars": 857,
"preview": "{\n \"editor.codeActionsOnSave\": {\n \"source.organizeImports.biome\": \"explicit\",\n \"source.fixAll.biome\": \"explicit\","
},
{
"path": "LICENSE",
"chars": 11357,
"preview": " Apache License\n Version 2.0, January 2004\n "
},
{
"path": "README.md",
"chars": 3928,
"preview": "<a href=\"https://novel.sh\">\n <img alt=\"Novel is a Notion-style WYSIWYG editor with AI-powered autocompletions.\" src=\"ht"
},
{
"path": "SECURITY.md",
"chars": 482,
"preview": "# Security Policy\n\n## Supported Versions\n\nWe release patches for security vulnerabilities. \n\n\n| Version | Supported "
},
{
"path": "apps/web/.gitignore",
"chars": 8,
"preview": ".vercel\n"
},
{
"path": "apps/web/.prettierignore",
"chars": 43,
"preview": "pnpm-lock.yaml\nyarn.lock\nnode_modules\n.next"
},
{
"path": "apps/web/app/api/generate/route.ts",
"chars": 4160,
"preview": "import { openai } from \"@ai-sdk/openai\";\nimport { Ratelimit } from \"@upstash/ratelimit\";\nimport { kv } from \"@vercel/kv\""
},
{
"path": "apps/web/app/api/upload/route.ts",
"chars": 843,
"preview": "import { put } from \"@vercel/blob\";\nimport { NextResponse } from \"next/server\";\n\nexport const runtime = \"edge\";\n\nexport "
},
{
"path": "apps/web/app/layout.tsx",
"chars": 1006,
"preview": "import \"@/styles/globals.css\";\nimport \"@/styles/prosemirror.css\";\nimport 'katex/dist/katex.min.css';\n\nimport type { Meta"
},
{
"path": "apps/web/app/page.tsx",
"chars": 1498,
"preview": "import TailwindAdvancedEditor from \"@/components/tailwind/advanced-editor\";\nimport { Button } from \"@/components/tailwin"
},
{
"path": "apps/web/app/providers.tsx",
"chars": 1099,
"preview": "\"use client\";\n\nimport { type Dispatch, type ReactNode, type SetStateAction, createContext } from \"react\";\nimport { Theme"
},
{
"path": "apps/web/biome.json",
"chars": 38,
"preview": "{\n \"extends\": [\"../../biome.json\"]\n}\n"
},
{
"path": "apps/web/components/tailwind/advanced-editor.tsx",
"chars": 5977,
"preview": "\"use client\";\nimport { defaultEditorContent } from \"@/lib/content\";\nimport {\n EditorCommand,\n EditorCommandEmpty,\n Ed"
},
{
"path": "apps/web/components/tailwind/extensions.ts",
"chars": 4023,
"preview": "import {\n AIHighlight,\n CharacterCount,\n CodeBlockLowlight,\n Color,\n CustomKeymap,\n GlobalDragHandle,\n HighlightE"
},
{
"path": "apps/web/components/tailwind/generative/ai-completion-command.tsx",
"chars": 1729,
"preview": "import { CommandGroup, CommandItem, CommandSeparator } from \"../ui/command\";\nimport { useEditor } from \"novel\";\nimport {"
},
{
"path": "apps/web/components/tailwind/generative/ai-selector-commands.tsx",
"chars": 1948,
"preview": "import { ArrowDownWideNarrow, CheckCheck, RefreshCcwDot, StepForward, WrapText } from \"lucide-react\";\nimport { getPrevTe"
},
{
"path": "apps/web/components/tailwind/generative/ai-selector.tsx",
"chars": 3696,
"preview": "\"use client\";\n\nimport { Command, CommandInput } from \"@/components/tailwind/ui/command\";\n\nimport { useCompletion } from "
},
{
"path": "apps/web/components/tailwind/generative/generative-menu-switch.tsx",
"chars": 1417,
"preview": "import { EditorBubble, removeAIHighlight, useEditor } from \"novel\";\nimport { Fragment, type ReactNode, useEffect } from "
},
{
"path": "apps/web/components/tailwind/image-upload.ts",
"chars": 1641,
"preview": "import { createImageUpload } from \"novel\";\nimport { toast } from \"sonner\";\n\nconst onUpload = (file: File) => {\n const p"
},
{
"path": "apps/web/components/tailwind/selectors/color-selector.tsx",
"chars": 4711,
"preview": "import { Check, ChevronDown } from \"lucide-react\";\nimport { EditorBubbleItem, useEditor } from \"novel\";\n\nimport { Button"
},
{
"path": "apps/web/components/tailwind/selectors/link-selector.tsx",
"chars": 3114,
"preview": "import { Button } from \"@/components/tailwind/ui/button\";\nimport { PopoverContent } from \"@/components/tailwind/ui/popov"
},
{
"path": "apps/web/components/tailwind/selectors/math-selector.tsx",
"chars": 891,
"preview": "import { Button } from \"@/components/tailwind/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport { SigmaIcon } from \"l"
},
{
"path": "apps/web/components/tailwind/selectors/node-selector.tsx",
"chars": 4207,
"preview": "import {\n Check,\n CheckSquare,\n ChevronDown,\n Code,\n Heading1,\n Heading2,\n Heading3,\n ListOrdered,\n type Lucide"
},
{
"path": "apps/web/components/tailwind/selectors/text-buttons.tsx",
"chars": 1889,
"preview": "import { Button } from \"@/components/tailwind/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport { BoldIcon, CodeIcon,"
},
{
"path": "apps/web/components/tailwind/slash-command.tsx",
"chars": 5427,
"preview": "import {\n CheckSquare,\n Code,\n Heading1,\n Heading2,\n Heading3,\n ImageIcon,\n List,\n ListOrdered,\n MessageSquareP"
},
{
"path": "apps/web/components/tailwind/ui/button.tsx",
"chars": 1774,
"preview": "import * as React from \"react\";\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { cva, type VariantProps } from \"cla"
},
{
"path": "apps/web/components/tailwind/ui/command.tsx",
"chars": 4857,
"preview": "\"use client\";\n\nimport type { DialogProps } from \"@radix-ui/react-dialog\";\nimport { Command as CommandPrimitive } from \"c"
},
{
"path": "apps/web/components/tailwind/ui/dialog.tsx",
"chars": 3777,
"preview": "\"use client\";\n\nimport * as React from \"react\";\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\";\nimport { X } f"
},
{
"path": "apps/web/components/tailwind/ui/icons/crazy-spinner.tsx",
"chars": 441,
"preview": "const CrazySpinner = () => {\n return (\n <div className=\"flex items-center justify-center gap-0.5\">\n <div classN"
},
{
"path": "apps/web/components/tailwind/ui/icons/font-default.tsx",
"chars": 2305,
"preview": "export default function FontDefault({ className }: { className?: string }) {\n return (\n <svg width=\"27\" height=\"17\" "
},
{
"path": "apps/web/components/tailwind/ui/icons/font-mono.tsx",
"chars": 2466,
"preview": "export default function FontMono({ className }: { className?: string }) {\n return (\n <svg\n width=\"28\"\n hei"
},
{
"path": "apps/web/components/tailwind/ui/icons/font-serif.tsx",
"chars": 4659,
"preview": "export default function FontSerif({ className }: { className?: string }) {\n return (\n <svg\n width=\"31\"\n he"
},
{
"path": "apps/web/components/tailwind/ui/icons/index.tsx",
"chars": 161,
"preview": "export { default as FontDefault } from \"./font-default\";\nexport { default as FontSerif } from \"./font-serif\";\nexport { d"
},
{
"path": "apps/web/components/tailwind/ui/icons/loading-circle.tsx",
"chars": 1403,
"preview": "export default function LoadingCircle({ dimensions }: { dimensions?: string }) {\n return (\n <svg\n aria-hidden=\""
},
{
"path": "apps/web/components/tailwind/ui/icons/magic.tsx",
"chars": 3869,
"preview": "export default function Magic({ className }: { className: string }) {\n return (\n <svg\n width=\"469\"\n height"
},
{
"path": "apps/web/components/tailwind/ui/menu.tsx",
"chars": 2704,
"preview": "\"use client\";\n\nimport { Check, Menu as MenuIcon, Monitor, Moon, SunDim } from \"lucide-react\";\nimport { useTheme } from \""
},
{
"path": "apps/web/components/tailwind/ui/popover.tsx",
"chars": 1254,
"preview": "\"use client\";\n\nimport * as React from \"react\";\nimport * as PopoverPrimitive from \"@radix-ui/react-popover\";\n\nimport { cn"
},
{
"path": "apps/web/components/tailwind/ui/scroll-area.tsx",
"chars": 1623,
"preview": "\"use client\";\n\nimport * as React from \"react\";\nimport * as ScrollAreaPrimitive from \"@radix-ui/react-scroll-area\";\n\nimpo"
},
{
"path": "apps/web/components/tailwind/ui/separator.tsx",
"chars": 707,
"preview": "\"use client\";\n\nimport * as React from \"react\";\nimport * as SeparatorPrimitive from \"@radix-ui/react-separator\";\n\nimport "
},
{
"path": "apps/web/components.json",
"chars": 354,
"preview": "{\n \"$schema\": \"https://ui.shadcn.com/schema.json\",\n \"style\": \"default\",\n \"rsc\": true,\n \"tsx\": true,\n \"tailwind\": {\n"
},
{
"path": "apps/web/hooks/use-local-storage.ts",
"chars": 676,
"preview": "import { useEffect, useState } from \"react\";\n\nconst useLocalStorage = <T>(\n key: string,\n initialValue: T,\n // eslint"
},
{
"path": "apps/web/lib/content.ts",
"chars": 9328,
"preview": "export const defaultEditorContent = {\n type: \"doc\",\n content: [\n {\n type: \"heading\",\n attrs: { level: 2 }"
},
{
"path": "apps/web/lib/utils.ts",
"chars": 169,
"preview": "import { type ClassValue, clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: C"
},
{
"path": "apps/web/next.config.js",
"chars": 1282,
"preview": "/** @type {import('next').NextConfig} */\nconst nextConfig = {\n redirects: async () => {\n return [\n {\n so"
},
{
"path": "apps/web/package.json",
"chars": 1609,
"preview": "{\n \"name\": \"novel-next-app\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"scripts\": {\n \"dev\": \"next dev\",\n \"build\""
},
{
"path": "apps/web/postcss.config.js",
"chars": 140,
"preview": "module.exports = {\n plugins: {\n \"postcss-import\": {},\n \"tailwindcss/nesting\": {},\n tailwindcss: {},\n autopr"
},
{
"path": "apps/web/styles/fonts.ts",
"chars": 984,
"preview": "import localFont from \"next/font/local\";\nimport { Crimson_Text, Inconsolata, Inter } from \"next/font/google\";\n\nexport co"
},
{
"path": "apps/web/styles/globals.css",
"chars": 3195,
"preview": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n@layer base {\n :root {\n --background: 0 0% 100%;\n --f"
},
{
"path": "apps/web/styles/prosemirror.css",
"chars": 6054,
"preview": ".ProseMirror {\n @apply p-12 px-8 sm:px-12;\n}\n\n.ProseMirror .is-editor-empty:first-child::before {\n content: attr(data-"
},
{
"path": "apps/web/tailwind.config.ts",
"chars": 2247,
"preview": "import type { Config } from \"tailwindcss\";\n\nconst config = {\n darkMode: [\"class\"],\n content: [\n \"./pages/**/*.{ts,t"
},
{
"path": "apps/web/tsconfig.json",
"chars": 281,
"preview": "{\n \"extends\": \"tsconfig/next.json\",\n\n \"compilerOptions\": {\n \"plugins\": [\n {\n \"name\": \"next\"\n }\n "
},
{
"path": "apps/web/vercel.json",
"chars": 225,
"preview": "{\n \"rewrites\": [\n {\n \"source\": \"/docs\",\n \"destination\": \"https://novel.mintlify.dev/docs\"\n },\n {\n "
},
{
"path": "biome.json",
"chars": 862,
"preview": "{\n \"$schema\": \"https://biomejs.dev/schemas/1.6.4/schema.json\",\n \"files\": {\n \"ignoreUnknown\": true,\n \"ignore\": [\n"
},
{
"path": "package.json",
"chars": 1574,
"preview": "{\n \"name\": \"novel\",\n \"private\": true,\n \"scripts\": {\n \"changeset\": \"changeset\",\n \"publish:packages\": \"changeset "
},
{
"path": "packages/headless/CHANGELOG.md",
"chars": 27042,
"preview": "# [0.2.0](https://github.com/steven-tey/novel/compare/v0.1.0...v0.2.0) (2025-01-17)\n\n## 1.0.0\n\n### Major Changes\n\n- clea"
},
{
"path": "packages/headless/biome.json",
"chars": 38,
"preview": "{\n \"extends\": [\"../../biome.json\"]\n}\n"
},
{
"path": "packages/headless/package.json",
"chars": 2299,
"preview": "{\n \"name\": \"novel\",\n \"version\": \"1.0.0\",\n \"description\": \"Notion-style WYSIWYG editor with AI-powered autocompletions"
},
{
"path": "packages/headless/src/components/editor-bubble-item.tsx",
"chars": 894,
"preview": "import { forwardRef } from \"react\";\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { useCurrentEditor } from \"@tipt"
},
{
"path": "packages/headless/src/components/editor-bubble.tsx",
"chars": 2366,
"preview": "import { BubbleMenu, isNodeSelection, useCurrentEditor } from \"@tiptap/react\";\nimport type { BubbleMenuProps } from \"@ti"
},
{
"path": "packages/headless/src/components/editor-command-item.tsx",
"chars": 1052,
"preview": "import { forwardRef } from \"react\";\nimport { CommandEmpty, CommandItem } from \"cmdk\";\nimport { useCurrentEditor } from \""
},
{
"path": "packages/headless/src/components/editor-command.tsx",
"chars": 2635,
"preview": "import { useAtom, useSetAtom } from \"jotai\";\nimport { useEffect, forwardRef, createContext } from \"react\";\nimport { Comm"
},
{
"path": "packages/headless/src/components/editor.tsx",
"chars": 1378,
"preview": "import { EditorProvider } from \"@tiptap/react\";\nimport type { EditorProviderProps, JSONContent } from \"@tiptap/react\";\ni"
},
{
"path": "packages/headless/src/components/index.ts",
"chars": 508,
"preview": "export { useCurrentEditor as useEditor } from \"@tiptap/react\";\nexport { type Editor as EditorInstance } from \"@tiptap/co"
},
{
"path": "packages/headless/src/extensions/ai-highlight.ts",
"chars": 2799,
"preview": "import { type Editor, Mark, markInputRule, markPasteRule, mergeAttributes } from \"@tiptap/core\";\n\nexport interface AIHig"
},
{
"path": "packages/headless/src/extensions/custom-keymap.ts",
"chars": 1546,
"preview": "import { Extension } from \"@tiptap/core\";\n\ndeclare module \"@tiptap/core\" {\n // eslint-disable-next-line no-unused-vars\n"
},
{
"path": "packages/headless/src/extensions/image-resizer.tsx",
"chars": 2065,
"preview": "import { useCurrentEditor } from \"@tiptap/react\";\nimport type { FC } from \"react\";\nimport Moveable from \"react-moveable\""
},
{
"path": "packages/headless/src/extensions/index.ts",
"chars": 2495,
"preview": "import { InputRule } from \"@tiptap/core\";\nimport { Color } from \"@tiptap/extension-color\";\nimport Highlight from \"@tipta"
},
{
"path": "packages/headless/src/extensions/mathematics.ts",
"chars": 4164,
"preview": "import { Node, mergeAttributes } from \"@tiptap/core\";\nimport { EditorState } from \"@tiptap/pm/state\";\nimport katex, { ty"
},
{
"path": "packages/headless/src/extensions/slash-command.tsx",
"chars": 2892,
"preview": "import { Extension } from \"@tiptap/core\";\nimport type { Editor, Range } from \"@tiptap/core\";\nimport { ReactRenderer } fr"
},
{
"path": "packages/headless/src/extensions/twitter.tsx",
"chars": 3303,
"preview": "import { Node, mergeAttributes, nodePasteRule } from \"@tiptap/core\";\nimport { NodeViewWrapper, ReactNodeViewRenderer, ty"
},
{
"path": "packages/headless/src/extensions/updated-image.ts",
"chars": 299,
"preview": "import Image from \"@tiptap/extension-image\";\n\nconst UpdatedImage = Image.extend({\n name: \"image\",\n addAttributes() {\n "
},
{
"path": "packages/headless/src/index.ts",
"chars": 1164,
"preview": "// Components\nexport {\n EditorRoot,\n EditorContent,\n type EditorContentProps,\n EditorBubble,\n EditorBubbleItem,\n E"
},
{
"path": "packages/headless/src/plugins/index.ts",
"chars": 161,
"preview": "export {\n UploadImagesPlugin,\n type UploadFn,\n type ImageUploadOptions,\n createImageUpload,\n handleImageDrop,\n han"
},
{
"path": "packages/headless/src/plugins/upload-images.tsx",
"chars": 4607,
"preview": "import { type EditorState, Plugin, PluginKey } from \"@tiptap/pm/state\";\nimport { Decoration, DecorationSet, type EditorV"
},
{
"path": "packages/headless/src/utils/atoms.ts",
"chars": 160,
"preview": "import { atom } from \"jotai\";\nimport type { Range } from \"@tiptap/core\";\n\nexport const queryAtom = atom(\"\");\nexport cons"
},
{
"path": "packages/headless/src/utils/index.ts",
"chars": 1214,
"preview": "import { Fragment, type Node } from \"@tiptap/pm/model\";\nimport type { EditorInstance } from \"../components\";\n\nexport fun"
},
{
"path": "packages/headless/src/utils/store.ts",
"chars": 168,
"preview": "import { createStore } from \"jotai\";\n\n// biome-ignore lint/suspicious/noExplicitAny: <explanation>\nexport const novelSto"
},
{
"path": "packages/headless/tsconfig.json",
"chars": 194,
"preview": "{\n \"extends\": \"tsconfig/react.json\",\n \"include\": [\"./src/**/*\"],\n \"exclude\": [\"dist\", \"build\", \"node_modules\"],\n \"co"
},
{
"path": "packages/headless/tsup.config.ts",
"chars": 293,
"preview": "import { defineConfig, Options } from \"tsup\";\n\nexport default defineConfig((options: Options) => ({\n entry: [\"src/index"
},
{
"path": "packages/tsconfig/base.json",
"chars": 831,
"preview": "{\n \"$schema\": \"https://json.schemastore.org/tsconfig\",\n \"compilerOptions\": {\n \"strict\": true,\n \"noUncheckedIndex"
},
{
"path": "packages/tsconfig/next.json",
"chars": 483,
"preview": "{\n \"$schema\": \"https://json.schemastore.org/tsconfig\",\n \"display\": \"Next.js\",\n \"extends\": \"./base.json\",\n \"compilerO"
},
{
"path": "packages/tsconfig/package.json",
"chars": 135,
"preview": "{\n \"name\": \"tsconfig\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"license\": \"MIT\",\n \"publishConfig\": {\n \"access\": "
},
{
"path": "packages/tsconfig/react.json",
"chars": 210,
"preview": "{\n \"$schema\": \"https://json.schemastore.org/tsconfig\",\n \"display\": \"React Library\",\n \"extends\": \"./base.json\",\n \"com"
},
{
"path": "pnpm-workspace.yaml",
"chars": 39,
"preview": "packages:\n - \"apps/*\"\n - \"packages/*\""
},
{
"path": "prettier.config.js",
"chars": 117,
"preview": "module.exports = {\n bracketSpacing: true,\n semi: true,\n trailingComma: \"all\",\n printWidth: 80,\n tabWidth: 2,\n};\n"
},
{
"path": "turbo.json",
"chars": 913,
"preview": "{\n \"$schema\": \"https://turbo.build/schema.json\",\n \"globalDependencies\": [\n \"**/.env.*local\"\n ],\n \"tasks\": {\n \""
}
]
// ... and 1 more files (download for full content)
About this extraction
This page contains the full source code of the steven-tey/novel GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 95 files (196.3 KB), approximately 62.1k tokens, and a symbol index with 84 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.