Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: steven-tey/novel
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: 0.1.22
Choose a base ref
...
head repository: steven-tey/novel
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: 0.2.0
Choose a head ref

Commits on Sep 5, 2023

  1. Merge pull request #1 from steven-tey/main

    merge header repo
    andrewdoro authored Sep 5, 2023
    Copy the full SHA
    9e171f0 View commit details
  2. Copy the full SHA
    adce09f View commit details
  3. fix: fix types errors

    andrewdoro committed Sep 5, 2023
    Copy the full SHA
    c7dfb18 View commit details
  4. Copy the full SHA
    978a29f View commit details
  5. Copy the full SHA
    feecc7a View commit details
  6. fix: remove unused styles

    andrewdoro committed Sep 5, 2023
    Copy the full SHA
    42dc95c View commit details

Commits on Sep 6, 2023

  1. Merge pull request #2 from steven-tey/main

    fix: link-selector
    andrewdoro authored Sep 6, 2023
    Copy the full SHA
    2b812ea View commit details
  2. fix: link dropdown

    andrewdoro committed Sep 6, 2023
    Copy the full SHA
    4f3f133 View commit details

Commits on Oct 3, 2023

  1. bump version

    steven-tey committed Oct 3, 2023
    1
    Copy the full SHA
    8ae8b3b View commit details

Commits on Oct 19, 2023

  1. rm .github

    steven-tey authored Oct 19, 2023
    1
    Copy the full SHA
    b4526fc View commit details

Commits on Jan 24, 2024

  1. Copy the full SHA
    62b70c1 View commit details

Commits on Jan 29, 2024

  1. Copy the full SHA
    bf71749 View commit details

Commits on Feb 4, 2024

  1. Copy the full SHA
    3521707 View commit details

Commits on Feb 7, 2024

  1. Copy the full SHA
    faf8c64 View commit details
  2. Copy the full SHA
    3c9e491 View commit details
  3. Copy the full SHA
    ded1012 View commit details

Commits on Feb 8, 2024

  1. Copy the full SHA
    db8eb96 View commit details
  2. feat: cleanup next app

    andrewdoro committed Feb 8, 2024
    Copy the full SHA
    fc0c45d View commit details
  3. feat: add wip ai example

    andrewdoro committed Feb 8, 2024
    Copy the full SHA
    fead332 View commit details
  4. Copy the full SHA
    8e60398 View commit details
  5. chore: fix more types

    andrewdoro committed Feb 8, 2024
    Copy the full SHA
    f03025e View commit details

Commits on Feb 9, 2024

  1. fix: add comment for todo

    andrewdoro committed Feb 9, 2024
    Copy the full SHA
    3a56333 View commit details
  2. Copy the full SHA
    85ac317 View commit details
  3. fix: add modal to popover

    andrewdoro committed Feb 9, 2024
    Copy the full SHA
    d27a572 View commit details
  4. feat: add ollama config

    andrewdoro committed Feb 9, 2024
    Copy the full SHA
    c2fa92f View commit details
  5. fix: close openai modal

    andrewdoro committed Feb 9, 2024
    Copy the full SHA
    b66afb7 View commit details
  6. Copy the full SHA
    af2b9a4 View commit details
  7. Copy the full SHA
    11e41d4 View commit details
  8. chore: remove old core

    andrewdoro committed Feb 9, 2024
    Copy the full SHA
    fcda444 View commit details
  9. Merge pull request #136 from andrewdoro/main

    RFC: Headless core components & imperative support
    andrewdoro authored Feb 9, 2024
    Copy the full SHA
    75d77cd View commit details
  10. feat: add starter minify

    andrewdoro committed Feb 9, 2024
    Copy the full SHA
    a65285e View commit details
  11. fix: rename to docs

    andrewdoro committed Feb 9, 2024
    Copy the full SHA
    a352bc1 View commit details
  12. Merge pull request #284 from andrewdoro/add-docs

    feat: add docs app
    andrewdoro authored Feb 9, 2024
    Copy the full SHA
    cd72180 View commit details

Commits on Feb 11, 2024

  1. Copy the full SHA
    0d4ad9c View commit details

Commits on Feb 12, 2024

  1. Copy the full SHA
    01cc183 View commit details
  2. Copy the full SHA
    03ba884 View commit details
  3. Copy the full SHA
    daffe6c View commit details
  4. Merge pull request #286 from brunocroh/fix/drag-handler-dark-mode

    fix: update dark mode class to drag handler component
    andrewdoro authored Feb 12, 2024
    Copy the full SHA
    74dc2c8 View commit details
  5. Copy the full SHA
    3087add View commit details
  6. feat: add components docs

    andrewdoro committed Feb 12, 2024
    Copy the full SHA
    62a5dbe View commit details
  7. Copy the full SHA
    fe078d2 View commit details
  8. Copy the full SHA
    af6284e View commit details
  9. Copy the full SHA
    cbbe155 View commit details
  10. Copy the full SHA
    f35c9db View commit details

Commits on Feb 13, 2024

  1. Copy the full SHA
    19631d7 View commit details
  2. Copy the full SHA
    e6fc416 View commit details
  3. Update README.md

    andrewdoro authored Feb 13, 2024
    Copy the full SHA
    ad79b1f View commit details
  4. Merge pull request #274 from justinjunodev/readme-fix-license-link

    [Fix] - Correct License Link in README.md
    andrewdoro authored Feb 13, 2024
    Copy the full SHA
    8a0f540 View commit details
  5. Copy the full SHA
    0768fff View commit details
  6. Copy the full SHA
    8f7995b View commit details
Showing with 4,627 additions and 8,971 deletions.
  1. +0 −10 .eslintrc.json
  2. +0 −3 .github/FUNDING.yml
  3. +5 −1 README.md
  4. +32 −0 apps/docs/README.md
  5. +22 −0 apps/docs/components/editor-bubble-item.mdx
  6. +22 −0 apps/docs/components/editor-bubble.mdx
  7. +31 −0 apps/docs/components/editor-command-item.mdx
  8. +20 −0 apps/docs/components/editor-command.mdx
  9. +48 −0 apps/docs/components/editor-content.mdx
  10. +19 −0 apps/docs/components/editor-root.mdx
  11. +23 −0 apps/docs/components/utils/use-editor.mdx
  12. +59 −0 apps/docs/development.mdx
  13. +9 −0 apps/docs/favicon.svg
  14. +6 −0 apps/docs/guides/ai-command.mdx
  15. +36 −0 apps/docs/guides/overview.mdx
  16. +52 −0 apps/docs/guides/tailwind/bubble-menu.mdx
  17. +136 −0 apps/docs/guides/tailwind/extensions.mdx
  18. +302 −0 apps/docs/guides/tailwind/setup.mdx
  19. +195 −0 apps/docs/guides/tailwind/slash-command.mdx
  20. BIN apps/docs/images/bubble-dark.png
  21. BIN apps/docs/images/bubble-light.png
  22. BIN apps/docs/images/hero-dark.png
  23. BIN apps/docs/images/hero-light.png
  24. +20 −0 apps/docs/introduction.mdx
  25. +9 −0 apps/docs/logo/logo.svg
  26. +77 −0 apps/docs/mint.json
  27. +81 −0 apps/docs/quickstart.mdx
  28. +181 −0 apps/docs/snippets/color-selector.mdx
  29. +94 −0 apps/docs/snippets/link-selector.mdx
  30. +133 −0 apps/docs/snippets/node-selector.mdx
  31. +63 −0 apps/docs/snippets/text-buttons.mdx
  32. +17 −12 apps/web/app/api/generate/route.ts
  33. +7 −3 apps/web/app/layout.tsx
  34. +13 −10 apps/web/app/page.tsx
  35. +10 −6 apps/web/app/providers.tsx
  36. +17 −0 apps/web/components.json
  37. +121 −0 apps/web/components/tailwind/editor.tsx
  38. +112 −0 apps/web/components/tailwind/extensions.ts
  39. +111 −0 apps/web/components/tailwind/selectors/ai-selector.tsx
  40. +52 −52 ...kages/core/src/ui/editor/bubble-menu → apps/web/components/tailwind/selectors}/color-selector.tsx
  41. +101 −0 apps/web/components/tailwind/selectors/link-selector.tsx
  42. +145 −0 apps/web/components/tailwind/selectors/node-selector.tsx
  43. +68 −0 apps/web/components/tailwind/selectors/text-buttons.tsx
  44. +161 −0 apps/web/components/tailwind/slash-command.tsx
  45. +56 −0 apps/web/components/tailwind/ui/button.tsx
  46. +155 −0 apps/web/components/tailwind/ui/command.tsx
  47. +122 −0 apps/web/components/tailwind/ui/dialog.tsx
  48. 0 apps/web/{ → components/tailwind}/ui/icons/font-default.tsx
  49. 0 apps/web/{ → components/tailwind}/ui/icons/font-mono.tsx
  50. 0 apps/web/{ → components/tailwind}/ui/icons/font-serif.tsx
  51. 0 apps/web/{ → components/tailwind}/ui/icons/github.tsx
  52. 0 apps/web/{ → components/tailwind}/ui/icons/index.tsx
  53. +2 −2 {packages/core/src → apps/web/components/tailwind}/ui/icons/loading-circle.tsx
  54. 0 {packages/core/src → apps/web/components/tailwind}/ui/icons/magic.tsx
  55. +33 −31 apps/web/{ → components/tailwind}/ui/menu.tsx
  56. +31 −0 apps/web/components/tailwind/ui/popover.tsx
  57. +31 −0 apps/web/components/tailwind/ui/separator.tsx
  58. 0 apps/web/{lib → }/hooks/use-local-storage.ts
  59. +0 −12 packages/core/src/ui/editor/default-content.tsx → apps/web/lib/content.ts
  60. +3 −3 apps/web/lib/utils.ts
  61. +18 −21 apps/web/package.json
  62. +0 −5,769 apps/web/pnpm-lock.yaml
  63. BIN {packages/core/src → apps/web}/styles/CalSans-SemiBold.otf
  64. 0 {packages/core/src → apps/web}/styles/fonts.ts
  65. +91 −2 apps/web/styles/globals.css
  66. +3 −4 {packages/core/src → apps/web}/styles/prosemirror.css
  67. +0 −5 apps/web/tailwind.config.js
  68. +81 −0 apps/web/tailwind.config.ts
  69. +2 −15 apps/web/tsconfig.json
  70. +0 −28 apps/web/ui/editor.tsx
  71. +0 −30 apps/web/ui/primitives/popover.tsx
  72. +12 −0 apps/web/vercel.json
  73. +6 −53 package.json
  74. +0 −139 packages/core/README.md
  75. +0 −9 packages/core/postcss.config.js
  76. +0 −5 packages/core/src/index.ts
  77. +0 −27 packages/core/src/lib/hooks/use-local-storage.ts
  78. +0 −26 packages/core/src/lib/utils.ts
  79. +0 −56 packages/core/src/styles/index.css
  80. +0 −3 packages/core/src/styles/tailwind.css
  81. +0 −137 packages/core/src/ui/editor/bubble-menu/index.tsx
  82. +0 −83 packages/core/src/ui/editor/bubble-menu/link-selector.tsx
  83. +0 −142 packages/core/src/ui/editor/bubble-menu/node-selector.tsx
  84. +0 −144 packages/core/src/ui/editor/extensions/index.tsx
  85. +0 −440 packages/core/src/ui/editor/extensions/slash-command.tsx
  86. +0 −222 packages/core/src/ui/editor/index.tsx
  87. +0 −53 packages/core/src/ui/editor/props.ts
  88. +0 −9 packages/core/src/ui/editor/provider.tsx
  89. +0 −2 packages/core/src/ui/icons/index.tsx
  90. +0 −7 packages/core/tailwind.config.js
  91. +25 −23 packages/{core → headless}/package.json
  92. +29 −0 packages/headless/src/components/editor-bubble-item.tsx
  93. +59 −0 packages/headless/src/components/editor-bubble.tsx
  94. +33 −0 packages/headless/src/components/editor-command-item.tsx
  95. +71 −0 packages/headless/src/components/editor-command.tsx
  96. +80 −0 packages/headless/src/components/editor.tsx
  97. +9 −0 packages/headless/src/components/index.ts
  98. 0 packages/{core/src/ui/editor → headless/src}/extensions/custom-keymap.ts
  99. +11 −23 packages/{core/src/ui/editor → headless/src}/extensions/drag-and-drop.tsx
  100. +9 −5 packages/{core/src/ui/editor → headless/src}/extensions/image-resizer.tsx
  101. +85 −0 packages/headless/src/extensions/index.ts
  102. +89 −0 packages/headless/src/extensions/slash-command.tsx
  103. +1 −0 packages/{core/src/ui/editor → headless/src}/extensions/updated-image.ts
  104. +1 −0 packages/headless/src/plugins/index.ts
  105. +6 −12 packages/{core/src/ui/editor → headless/src}/plugins/upload-images.tsx
  106. +26 −0 packages/{core/src/lib/editor.ts → headless/src/utils/utils.ts}
  107. +3 −5 packages/{core → headless}/tsconfig.json
  108. +5 −1 packages/{core → headless}/tsup.config.ts
  109. +0 −15 packages/tailwind-config/package.json
  110. +0 −39 packages/tailwind-config/tailwind.config.js
  111. +20 −13 packages/tsconfig/base.json
  112. +1 −5 packages/tsconfig/next.json
  113. +0 −1 packages/tsconfig/react.json
  114. +808 −1,253 pnpm-lock.yaml
10 changes: 0 additions & 10 deletions .eslintrc.json

This file was deleted.

3 changes: 0 additions & 3 deletions .github/FUNDING.yml

This file was deleted.

6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -26,6 +26,9 @@
</p>
<br/>

## Docs (WIP)
https://novel.sh/docs/introduction

## Introduction

[Novel](https://novel.sh/) is a Notion-style WYSIWYG editor with AI-powered autocompletions.
@@ -34,6 +37,7 @@ https://github.com/steven-tey/novel/assets/28986134/2099877f-4f2b-4b1c-8782-5d80

<br />


## Installation

To use Novel in a project, you can run the following command to install the `novel` [NPM package](https://www.npmjs.com/package/novel):
@@ -136,4 +140,4 @@ Here's how you can contribute:

## License

Licensed under the [Apache-2.0 license](https://github.com/steven-tey/novel/blob/main/LICENSE.md).
Licensed under the [Apache-2.0 license](https://github.com/steven-tey/novel/blob/main/LICENSE).
32 changes: 32 additions & 0 deletions apps/docs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Mintlify Starter Kit

Click on `Use this template` to copy the Mintlify starter kit. The starter kit contains examples including

- Guide pages
- Navigation
- Customizations
- API Reference pages
- Use of popular components

### Development

Install the [Mintlify CLI](https://www.npmjs.com/package/mintlify) to preview the documentation changes locally. To install, use the following command

```
npm i -g mintlify
```

Run the following command at the root of your documentation (where mint.json is)

```
mintlify dev
```

### Publishing Changes

Install our Github App to autopropagate changes from youre repo to your deployment. Changes will be deployed to production automatically after pushing to the default branch. Find the link to install on your dashboard.

#### Troubleshooting

- Mintlify dev isn't running - Run `mintlify install` it'll re-install dependencies.
- Page loads as a 404 - Make sure you are running in a folder with `mint.json`
22 changes: 22 additions & 0 deletions apps/docs/components/editor-bubble-item.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
title: "Editor Bubble Item"
description: "Bubble Item"
---

```tsx
<EditorBubbleItem
key={index}
onSelect={(editor) => {
item.command(editor);
}}>
...
</EditorBubbleItem>
```

## Props

<ParamField required path='children' type='ReactNode'></ParamField>

<ParamField path='className' type='string'></ParamField>

<ParamField path='onSelect' type='(editor: Editor) => void'></ParamField>
22 changes: 22 additions & 0 deletions apps/docs/components/editor-bubble.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
title: "Editor Bubble"
description: "Wrapper over Tiptap Bubble menu"
---

For all the available props, see [Bubble Menu](https://tiptap.dev/docs/editor/api/extensions/bubble-menu).

```tsx
<EditorBubble>
<EditorBubbleItem />
<EditorBubbleItem />
<EditorBubbleItem />
</EditorBubble>
```

## Props

<ParamField required path='children' type='ReactNode'></ParamField>

<ParamField path='className' type='string'></ParamField>

<ParamField path='tippyOptions' type='Props'></ParamField>
31 changes: 31 additions & 0 deletions apps/docs/components/editor-command-item.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
title: "Editor Command Item"
description: "Command Item"
---

For all the available props, see [cmdk](https://github.com/pacocoursey/cmdk).

```tsx
...
<EditorCommandItem
value={item.title}
onCommand={(val) => item.command(val)}>
Do something
</EditorCommand>
```

## Props

<ParamField required path='children' type='ReactNode'></ParamField>
<ParamField required path='value' type='string'>
This value would be used for filtering
</ParamField>

<ParamField
required
path='onCommand'
type='({ editor, range }: { editor: Editor; range: Range }) => void;'>
Callback function onSelect exposing editor and range
</ParamField>

<ParamField path='className' type='string'></ParamField>
20 changes: 20 additions & 0 deletions apps/docs/components/editor-command.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
title: "Editor Command"
description: "Wrapper for Command Items using cmdk"
---

For all the available props, see [cmdk](https://github.com/pacocoursey/cmdk).

```tsx
<EditorCommand>
<EditorCommandItem />
<EditorCommandItem />
<EditorCommandItem />
</EditorCommand>
```

## Props

<ParamField required path='children' type='ReactNode'></ParamField>

<ParamField path='className' type='string'></ParamField>
48 changes: 48 additions & 0 deletions apps/docs/components/editor-content.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
title: "Editor Content"
description: "Wrapper for Tiptap Provider "
---

For all the available props, see [Tiptap Settings](https://tiptap.dev/docs/editor/api/editor#settings).

```tsx
<EditorRoot>
<EditorContent>{children}</EditorContent>
</EditorRoot>
```

## Props

<ParamField required path='children' type='ReactNode'>
A ReactNode that represents the content of the editor.
</ParamField>

<ParamField path='extensions' type='Extension[]' required>
An array of Tiptap extensions to be used in the editor.
</ParamField>

<ParamField path='initialContent' type='JSONContent'>
Initial editor content in JSON format. [Tiptap
Output](https://tiptap.dev/docs/editor/guide/output)
</ParamField>

<ParamField
path='onUpdate'
type='(props: {
editor: Editor;
transaction: Transaction;
}) => void'>
Function that is called when the editor content is updated.
</ParamField>

<ParamField
path='onCreate'
type='onCreate?: (props: {
editor: Editor;
}) => void'>
Function that is called when the editor is created.
</ParamField>

<ParamField path='className' type='string'>
Classname for the parent container.
</ParamField>
19 changes: 19 additions & 0 deletions apps/docs/components/editor-root.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
title: "Editor Root"
description: "A wrapper component for the editor. It provides a consistent layout and styling for the editor."
---

<Info>
This example demonstrates the use of Shadcn-ui for ui, but alternative libraries and components
can also be employed.
</Info>

```tsx
<EditorRoot>{...}</EditorRoot>
```

## Props

<ParamField required path='children' type='ReactNode'>
A ReactNode that represents the content of the editor.
</ParamField>
23 changes: 23 additions & 0 deletions apps/docs/components/utils/use-editor.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
title: "useEditor"
description: "Imperative API for interacting with the editor."
---

Your component must be a child of `EditorRoot` to use this hook.

```tsx
const CustomComponent = ({ open, onOpenChange }: LinkSelectorProps) => {
const { editor } = useEditor();
...
}

<EditorRoot>
<CustomComponent/>
</EditorRoot>
```

## Props

<ParamField path='editor' type='Editor'>
All methods are available here [Editor](https://tiptap.dev/docs/editor/api/editor)
</ParamField>
59 changes: 59 additions & 0 deletions apps/docs/development.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
title: "Development"
description: "Learn how to contribute to Novel"
---

<Info>**Prerequisite** You should have installed Node.js (version 18.10.0 or higher).</Info>

## Introduction

Novel's codebase is set up in a monorepo (via [Turborepo](https://turbo.build/repo)) and is fully [open-source on GitHub](https://github.com/steven-tey/novel).

Here's the monorepo structure:

```
apps
├── docs
├── web
packages
├── headless
├── tailwind-config
```

### Step 1: Local setup

First, clone the [Novel repo](https://dub.sh/github)

```bash
git clone https://github.com/steven-tey/novel
```

Run the following command to install the dependencies:

```bash
pnpm i
```

Install Mintlify CLI (for docs server):

```bash
pnpm i -g mintlify
```

### Step 2: Start the development server

Finally, you can start the development server. This will build the packages + start the app servers.

```bash
pnpm dev
```

### Step 3: Use Generative AI Local (Optional)

You can use Ollama to run your local AI server.\
https://ollama.com/blog/openai-compatibility

```bash
# You cand find the config in the web app
/api/generate/route.ts
```
9 changes: 9 additions & 0 deletions apps/docs/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions apps/docs/guides/ai-command.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
title: "AI Command (Soon)"
description: "Run AI commands in your editor"
---

Comming soon
36 changes: 36 additions & 0 deletions apps/docs/guides/overview.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
title: "Overview"
description: "Use Novel with your favorite styling or components"
---

## Active integrations

<Card
title='Tailwind'
icon={
<svg className='h-8 w-8' xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 54 33">
<g clipPath="url(#prefix__clip0)">
<path fill="#38bdf8" fillRule="evenodd" d="M27 0c-7.2 0-11.7 3.6-13.5 10.8 2.7-3.6 5.85-4.95 9.45-4.05 2.054.513 3.522 2.004 5.147 3.653C30.744 13.09 33.808 16.2 40.5 16.2c7.2 0 11.7-3.6 13.5-10.8-2.7 3.6-5.85 4.95-9.45 4.05-2.054-.513-3.522-2.004-5.147-3.653C36.756 3.11 33.692 0 27 0zM13.5 16.2C6.3 16.2 1.8 19.8 0 27c2.7-3.6 5.85-4.95 9.45-4.05 2.054.514 3.522 2.004 5.147 3.653C17.244 29.29 20.308 32.4 27 32.4c7.2 0 11.7-3.6 13.5-10.8-2.7 3.6-5.85 4.95-9.45 4.05-2.054-.513-3.522-2.004-5.147-3.653C23.256 19.31 20.192 16.2 13.5 16.2z" />
</g>
<defs>
<clipPath id="prefix__clip0">
<path fill="#fff" d="M0 0h54v32.4H0z"/>
</clipPath>
</defs>
</svg>
}
href='/guides/tailwind'>
Usage with Tailwind

</Card>

## Upcoming integrations

We are working on having more guides in the futures:

<Card>
<b>CSS</b>
</Card>
<Card href='https://panda-css.com/'>
<b>Panda CSS</b>
</Card>
52 changes: 52 additions & 0 deletions apps/docs/guides/tailwind/bubble-menu.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
title: "Bubble Menu"
description: "Showcase of the Bubble Menu component in various configurations."
---

import NodeSelectorSnippet from "/snippets/node-selector.mdx";
import ColorSelectorSnippet from "/snippets/color-selector.mdx";
import LinkSelectorSnippet from "/snippets/link-selector.mdx";
import TextButtonsSnippet from "/snippets/text-buttons.mdx";

<img className='block dark:hidden' src='/images/bubble-light.png' alt='Hero Dark' />
<img className='hidden dark:block' src='/images/bubble-dark.png' alt='Hero Dark' />

We first have to create the selectors for the different types of nodes and links. We can then use these selectors to create the bubble menu.

<AccordionGroup>
<Accordion title='Node Selector' icon='share-nodes'>
<NodeSelectorSnippet />
</Accordion>
<Accordion title='Link Selector' icon='link'>
<LinkSelectorSnippet />
</Accordion>
<Accordion title='Text Buttons' icon='bold'>
<TextButtonsSnippet />
</Accordion>
<Accordion title='Color Selector' icon='palette'>
<ColorSelectorSnippet />
</Accordion>
</AccordionGroup>

```tsx editor.tsx
import { NodeSelector } from "./selectors/node-selector";
import { LinkSelector } from "./selectors/link-selector";
import { ColorSelector } from "./selectors/color-selector";
import { TextButtons } from "./selectors/text-buttons";


...
<EditorContent>
<EditorBubble
tippyOptions={{
placement: openAI ? "bottom-start" : "top",
}}
className='flex w-fit max-w-[90vw] overflow-hidden rounded border border-muted bg-background shadow-xl'>
<NodeSelector open={openNode} onOpenChange={setOpenNode} />
<LinkSelector open={openLink} onOpenChange={setOpenLink} />
<TextButtons />
<ColorSelector open={openColor} onOpenChange={setOpenColor} />
</EditorBubble>
</EditorContent>;
...
```
136 changes: 136 additions & 0 deletions apps/docs/guides/tailwind/extensions.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
---
title: "Extensions"
description: "Styled and configured Tiptap extensions for your editor"
---

<Info>You can use any Tiptap extensions or create your own.</Info>

## Default Extensions

```tsx extensions.ts
import {
TiptapImage,
TiptapLink,
UpdatedImage,
TaskList,
TaskItem,
HorizontalRule,
StarterKit,
Placeholder,
} from "novel/extensions";
import { UploadImagesPlugin } from "novel/plugins";

import { cx } from "class-variance-authority";
import { slashCommand } from "./slash-command";

//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

//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()];
},
}).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 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-sm bg-muted 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,
});

export const defaultExtensions = [
starterKit,
placeholder,
tiptapLink,
tiptapImage,
updatedImage,
taskList,
taskItem,
horizontalRule,
];
```

<Note>
For intellisense in your VS Code editor you can also add this regex to the `settings.json`

```json
"tailwindCSS.experimental.classRegex":[["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]],
```

</Note>

## Custom Extension

Coming soon
302 changes: 302 additions & 0 deletions apps/docs/guides/tailwind/setup.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
---
title: "Setup"
description: "Follow this guide to set up Novel with Tailwindcss"
---

<Info>
This example demonstrates the use of Shadcn-ui for ui, but alternative libraries and components
can also be employed.
</Info>

<Card title='Shadcn-ui' icon='link' href='https://ui.shadcn.com/docs/installation'>
You can find more info about installing shadcn-ui here. You will need to add the following
components: <b>Button, Separator, Popover, Command, Dialog,</b>
</Card>

This example will use the same stucture from here: [Anatomy](/quickstart#anatomy)

## Configure Wrapper

```tsx
"use client";

import { EditorContent, EditorRoot } from "novel";
import { useState } from "react";

const Editor = () => {
const [content, setContent] = useState(null);
return (
<EditorRoot>
<EditorContent
initialContent={content}
onUpdate={({ editor }) => {
const json = editor.getJSON();
setContent(json);
}}
/>
</EditorRoot>
);
};
export default Editor;
```
<Tip>
`onUpdate` runs on every change. In most cases, you will want to debounce the updates to prevent too many state changes.
```tsx
const debouncedUpdates = useDebouncedCallback(async (editor: Editor) => {
const json = editor.getJSON();
setContent(json);
setSaveStatus("Saved");
}, 500);

onUpdate={debouncedUpdates};
```
</Tip>

## Configure Extensions

<Card title='Extensions' icon='link' href='/guides/tailwind/extensions'>
You can find here example of extensions
</Card>

```tsx
import { defaultExtensions } from "./extensions";

const extensions = [...defaultExtensions];

<EditorContent
extensions={extensions}
...
/>;
```

## Create Menus

<CardGroup cols={2}>
<Card title='Slash Command' href='/guides/tailwind/slash-command' icon='terminal'>
Slash commands are a way to quickly insert content into the editor.
</Card>
<Card title='Bubble Menu' href='/guides/tailwind/bubble-menu' icon='square-caret-down'>
The bubble menu is a context menu that appears when you select text.
</Card>
</CardGroup>

## Add Styles

<AccordionGroup>
<Accordion title='Prosemirror Styles' icon='css3'>
```css 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: var(--novel-stone-400);
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: var(--novel-white);
margin: 0;
cursor: pointer;
width: 1.2em;
height: 1.2em;
position: relative;
top: 5px;
border: 2px solid var(--novel-stone-900);
margin-right: 0.3rem;
display: grid;
place-content: center;

&:hover {
background-color: var(--novel-stone-50);
}

&:active {
background-color: var(--novel-stone-200);
}

&::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(--novel-stone-400);
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;
}

&.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");
}

```

</Accordion>
<Accordion title="Globals CSS" icon="globe">
Update your globals.css with Novel css vars
```css globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
:root {
...
--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 {
....

--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;

}
}

```

</Accordion>
</AccordionGroup>
195 changes: 195 additions & 0 deletions apps/docs/guides/tailwind/slash-command.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
---
title: "Slash Command"
description: "Create a slash command to insert content into the editor."
---

## Define suggestions

We export a helper to define the suggestions that will be shown in the command palette. `createSuggestionItems`

```tsx
import {
CheckSquare,
Code,
Heading1,
Heading2,
Heading3,
ImageIcon,
List,
ListOrdered,
MessageSquarePlus,
Text,
TextQuote,
} from "lucide-react";
import { createSuggestionItems } from "novel/extensions";
import { startImageUpload } from "novel/plugins";
import { Command, renderItems } from "novel/extensions";

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;
startImageUpload(file, editor.view, pos);
}
};
input.click();
},
},
]);

export const slashCommand = Command.configure({
suggestion: {
items: () => suggestionItems,
render: renderItems,
},
});
```

## Register the command

We need to add the command to the extensions array

```tsx
const extensions = [...defaultExtensions, slashCommand];

<EditorContent
extensions={extensions}
...
/>;
```

## Create UI for the command

We map the suggestionItems and use the `EditorCommand` and `EditorCommandItem` components to create the UI for the command palette.
Components are wrapper over cmdk

```tsx
...
<EditorContent>
<EditorCommand className='z-50 h-auto max-h-[330px] w-72 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>
{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>
))}
</EditorCommand>
</EditorContent>
...
```
Binary file added apps/docs/images/bubble-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/docs/images/bubble-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/docs/images/hero-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/docs/images/hero-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions apps/docs/introduction.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
title: Introduction
description: "Novel is a headless Notion-style WYSIWYG editor"
---

<img className='block dark:hidden' src='/images/hero-light.png' alt='Hero Light' />
<img className='hidden dark:block' src='/images/hero-dark.png' alt='Hero Dark' />

## Features

TODO: Add features

## Tech Stack

Novel's codebase is [fully open-source](https://github.com/steven-tey/novel) and is built on top of the following technologies:

- [Tiptap](https://tiptap.dev/) – framework
- [TypeScript](https://www.typescriptlang.org/) – language
- [RadixUI](https://www.radix-ui.com/primitives) – components
- [Cmdk](https://cmdk.paco.me/) – command
9 changes: 9 additions & 0 deletions apps/docs/logo/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
77 changes: 77 additions & 0 deletions apps/docs/mint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
{
"$schema": "https://mintlify.com/schema.json",
"name": "Novel Docs",
"logo": {
"dark": "/logo/logo.svg",
"light": "/logo/logo.svg"
},
"favicon": "/favicon.svg",
"colors": {
"primary": "#334155",
"light": "#cbd5e1",
"dark": "#334155",
"background": {
"dark": "#020817"
}
},

"topbarCtaButton": {
"name": "Demo",
"url": "https://novel.sh/"
},

"anchors": [
{
"name": "Components",
"icon": "react",
"url": "components"
},
{
"name": "Github",
"icon": "github",
"url": "https://github.com/steven-tey/novel"
}
],
"navigation": [
{
"group": "Get Started",
"pages": ["introduction", "quickstart", "development"]
},
{
"group": "Guides",
"pages": [
"guides/overview",
{
"group": "Using Tailwind",
"pages": [
"guides/tailwind/setup",
"guides/tailwind/extensions",
"guides/tailwind/slash-command",
"guides/tailwind/bubble-menu"
]
},
"guides/ai-command"
]
},
{
"group": "Components",
"pages": [
"components/editor-root",
"components/editor-content",
"components/editor-command",
"components/editor-command-item",
"components/editor-bubble",
"components/editor-bubble-item"
]
},
{
"group": "Utils",
"pages": ["components/utils/use-editor"]
}
],
"footerSocials": {
"twitter": "https://twitter.com/mintlify",
"github": "https://github.com/mintlify",
"linkedin": "https://www.linkedin.com/company/mintsearch"
}
}
81 changes: 81 additions & 0 deletions apps/docs/quickstart.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
---
title: "Quickstart"
description: "Start using the editor in 5 minutes"
---

## Installation

Novel package doeesn't have any styles included. It's just a colection of custom configs and components for Tiptap.

<CodeGroup>

```bash npm
npm i novel
```

```bash yarn
yarn add novel
```

```bash pnpm
pnpm add novel
```

</CodeGroup>

## Usage

<CardGroup>
<Card
title='Tailwind'
icon={
<svg className='h-8 w-8' xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 54 33">
<g clipPath="url(#prefix__clip0)">
<path fill="#38bdf8" fillRule="evenodd" d="M27 0c-7.2 0-11.7 3.6-13.5 10.8 2.7-3.6 5.85-4.95 9.45-4.05 2.054.513 3.522 2.004 5.147 3.653C30.744 13.09 33.808 16.2 40.5 16.2c7.2 0 11.7-3.6 13.5-10.8-2.7 3.6-5.85 4.95-9.45 4.05-2.054-.513-3.522-2.004-5.147-3.653C36.756 3.11 33.692 0 27 0zM13.5 16.2C6.3 16.2 1.8 19.8 0 27c2.7-3.6 5.85-4.95 9.45-4.05 2.054.514 3.522 2.004 5.147 3.653C17.244 29.29 20.308 32.4 27 32.4c7.2 0 11.7-3.6 13.5-10.8-2.7 3.6-5.85 4.95-9.45 4.05-2.054-.513-3.522-2.004-5.147-3.653C23.256 19.31 20.192 16.2 13.5 16.2z" />
</g>
<defs>
<clipPath id="prefix__clip0">
<path fill="#fff" d="M0 0h54v32.4H0z"/>
</clipPath>
</defs>
</svg>
}
href='/guides/tailwind'>
Usage with Tailwind and Shadcn-UI

</Card>
<Card icon="react" href="/components" title='Custom'>
Write your own styles using the Novel components
</Card>
</CardGroup>
## Anatomy

This is mostly how you would use the editor in your application. Similar to Radix Primitives, Novel exports a set of components that you can use to build your own editor.

```tsx
import {
EditorBubble,
EditorBubbleItem,
EditorCommand,
EditorCommandItem,
EditorContent,
EditorRoot,
} from "novel";

export default () => (
<EditorRoot>
<EditorContent>
<EditorCommand>
<EditorCommandItem />
<EditorCommandItem />
<EditorCommandItem />
</EditorCommand>
<EditorBubble>
<EditorBubbleItem />
<EditorBubbleItem />
<EditorBubbleItem />
</EditorBubble>
</EditorContent>
</EditorRoot>
);
```
181 changes: 181 additions & 0 deletions apps/docs/snippets/color-selector.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
```tsx color-selector.tsx
import { Check, ChevronDown } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
import { EditorBubbleItem, useEditor } from "novel";

import { PopoverTrigger, Popover, PopoverContent } from "@/components/tailwind/ui/popover";
import { Button } from "@/components/tailwind/ui/button";
export interface BubbleColorMenuItem {
name: string;
color: string;
}

interface ColorSelectorProps {
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}

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 }) => {
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 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 }, index) => (
<EditorBubbleItem
key={index}
onSelect={() => {
editor.commands.unsetColor();
name !== "Default" &&
editor
.chain()
.focus()
.setColor(color || "")
.run();
}}
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 }, index) => (
<EditorBubbleItem
key={index}
onSelect={() => {
editor.commands.unsetHighlight();
name !== "Default" && editor.commands.setHighlight({ color });
}}
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>
);
};
```
94 changes: 94 additions & 0 deletions apps/docs/snippets/link-selector.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
```tsx link-selector.tsx
import { cn } from "@/lib/utils";
import { useEditor } from "novel";
import { Check, Trash } from "lucide-react";
import { type Dispatch, type FC, type SetStateAction, useEffect, useRef } from "react";
import { Popover, PopoverTrigger } from "@radix-ui/react-popover";
import { Button } from "@/components/tailwind/ui/button";
import { PopoverContent } from "@/components/tailwind/ui/popover";

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 && inputRef.current?.focus();
});
if (!editor) return null;

return (
<Popover modal={true} open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button 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);
url && editor.chain().focus().setLink({ href: url }).run();
}}
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();
}}>
<Trash className='h-4 w-4' />
</Button>
) : (
<Button size='icon' className='h-8'>
<Check className='h-4 w-4' />
</Button>
)}
</form>
</PopoverContent>
</Popover>
);
};
```
133 changes: 133 additions & 0 deletions apps/docs/snippets/node-selector.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
```tsx node-selector.tsx
import {
Check,
ChevronDown,
Heading1,
Heading2,
Heading3,
TextQuote,
ListOrdered,
TextIcon,
Code,
CheckSquare,
type LucideIcon,
} from "lucide-react";
import { EditorBubbleItem, useEditor } from "novel";

import { Popover } from "@radix-ui/react-popover";
import { PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";

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().toggleNode("paragraph", "paragraph").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().toggleHeading({ level: 1 }).run(),
isActive: (editor) => editor.isActive("heading", { level: 1 }),
},
{
name: "Heading 2",
icon: Heading2,
command: (editor) => editor.chain().focus().toggleHeading({ level: 2 }).run(),
isActive: (editor) => editor.isActive("heading", { level: 2 }),
},
{
name: "Heading 3",
icon: Heading3,
command: (editor) => editor.chain().focus().toggleHeading({ level: 3 }).run(),
isActive: (editor) => editor.isActive("heading", { level: 3 }),
},
{
name: "To-do List",
icon: CheckSquare,
command: (editor) => editor.chain().focus().toggleTaskList().run(),
isActive: (editor) => editor.isActive("taskItem"),
},
{
name: "Bullet List",
icon: ListOrdered,
command: (editor) => editor.chain().focus().toggleBulletList().run(),
isActive: (editor) => editor.isActive("bulletList"),
},
{
name: "Numbered List",
icon: ListOrdered,
command: (editor) => editor.chain().focus().toggleOrderedList().run(),
isActive: (editor) => editor.isActive("orderedList"),
},
{
name: "Quote",
icon: TextQuote,
command: (editor) =>
editor.chain().focus().toggleNode("paragraph", "paragraph").toggleBlockquote().run(),
isActive: (editor) => editor.isActive("blockquote"),
},
{
name: "Code",
icon: Code,
command: (editor) => editor.chain().focus().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 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, index) => (
<EditorBubbleItem
key={index}
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>
);
};
```
63 changes: 63 additions & 0 deletions apps/docs/snippets/text-buttons.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
```tsx text-buttons.tsx
import { cn } from "@/lib/utils";
import { EditorBubbleItem, useEditor } from "novel";
import { BoldIcon, ItalicIcon, UnderlineIcon, StrikethroughIcon, CodeIcon } from "lucide-react";
import type { SelectorItem } from "./node-selector";
import { Button } from "@/components/tailwind/ui/button";

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, index) => (
<EditorBubbleItem
key={index}
onSelect={(editor) => {
item.command(editor);
}}>
<Button size='icon' className='rounded-none' variant='ghost'>
<item.icon
className={cn("h-4 w-4", {
"text-blue-500": item.isActive(editor),
})}
/>
</Button>
</EditorBubbleItem>
))}
</div>
);
};
```
29 changes: 17 additions & 12 deletions apps/web/app/api/generate/route.ts
Original file line number Diff line number Diff line change
@@ -4,28 +4,33 @@ import { kv } from "@vercel/kv";
import { Ratelimit } from "@upstash/ratelimit";

// Create an OpenAI API client (that's edge friendly!)
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY || "",
});
// Using LLamma's OpenAI client:

// IMPORTANT! Set the runtime to edge: https://vercel.com/docs/functions/edge-functions/edge-runtime
export const runtime = "edge";

const isProd = process.env.NODE_ENV === "production";

export async function POST(req: Request): Promise<Response> {
const openai = new OpenAI({
...(!isProd && {
baseURL: "http://localhost:11434/v1",
}),
apiKey: isProd ? process.env.OPENAI_API_KEY : "ollama",
});
// Check if the OPENAI_API_KEY is set, if not return 400
if (!process.env.OPENAI_API_KEY || process.env.OPENAI_API_KEY === "") {
if (
(!process.env.OPENAI_API_KEY || process.env.OPENAI_API_KEY === "") &&
isProd
) {
return new Response(
"Missing OPENAI_API_KEY – make sure to add it to your .env file.",
"Missing OPENAI_API_KEY - make sure to add it to your .env file.",
{
status: 400,
},
);
}
if (
process.env.NODE_ENV != "development" &&
process.env.KV_REST_API_URL &&
process.env.KV_REST_API_TOKEN
) {
if (isProd && 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,
@@ -51,7 +56,8 @@ export async function POST(req: Request): Promise<Response> {
let { prompt } = await req.json();

const response = await openai.chat.completions.create({
model: "gpt-3.5-turbo",
model: process.env.NODE_ENV == "development" ? "llama2" : "gpt-3.5-turbo",
stream: true,
messages: [
{
role: "system",
@@ -71,7 +77,6 @@ export async function POST(req: Request): Promise<Response> {
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
stream: true,
n: 1,
});

10 changes: 7 additions & 3 deletions apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import "@/styles/globals.css";
import "@/styles/prosemirror.css";

import { Metadata } from "next";
import { ReactNode } from "react";
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";
"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.";

@@ -23,6 +24,9 @@ export const metadata: Metadata = {
creator: "@steventey",
},
metadataBase: new URL("https://novel.sh"),
};

export const viewport: Viewport = {
themeColor: "#ffffff",
};

23 changes: 13 additions & 10 deletions apps/web/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import { Github } from "@/ui/icons";
import Menu from "@/ui/menu";
import Editor from "@/ui/editor";
import { Github } from "@/components/tailwind/ui/icons";
import { Button } from "@/components/tailwind/ui/button";
import Menu from "@/components/tailwind/ui/menu";
import TailwindEditor from "@/components/tailwind/editor";

export default function Page() {
return (
<div className="flex min-h-screen flex-col items-center sm:px-5 sm:pt-[calc(20vh)]">
<a
href="https://github.com/steven-tey/novel"
target="_blank"
className="absolute bottom-5 left-5 z-10 max-h-fit rounded-lg p-2 transition-colors duration-200 hover:bg-stone-100 sm:bottom-auto sm:top-5"
<Button
size="icon"
variant="outline"
className="absolute bottom-5 left-5 z-10 max-h-fit rounded-lg p-2 transition-colors duration-200 sm:bottom-auto sm:top-5"
>
<Github />
</a>
<a href="https://github.com/steven-tey/novel" target="_blank">
<Github />
</a>
</Button>
<Menu />
<Editor />
<TailwindEditor />
</div>
);
}
16 changes: 10 additions & 6 deletions apps/web/app/providers.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
"use client";

import { Dispatch, ReactNode, SetStateAction, createContext } from "react";
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 "@/lib/hooks/use-local-storage";
import useLocalStorage from "@/hooks/use-local-storage";

export const AppContext = createContext<{
font: string;
@@ -27,10 +32,9 @@ export default function Providers({ children }: { children: ReactNode }) {
return (
<ThemeProvider
attribute="class"
value={{
light: "light-theme",
dark: "dark-theme",
}}
enableSystem
disableTransitionOnChange
defaultTheme="system"
>
<AppContext.Provider
value={{
17 changes: 17 additions & 0 deletions apps/web/components.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"$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"
}
}
121 changes: 121 additions & 0 deletions apps/web/components/tailwind/editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"use client";
import { defaultEditorContent } from "@/lib/content";
import React, { useEffect, useState } from "react";
import { useDebouncedCallback } from "use-debounce";
import {
defaultEditorProps,
Editor,
EditorRoot,
EditorBubble,
EditorCommand,
EditorCommandItem,
EditorCommandEmpty,
EditorContent,
type JSONContent,
} from "novel";
import { ImageResizer } from "novel/extensions";
import { defaultExtensions } from "./extensions";
import { Separator } from "./ui/separator";
import { NodeSelector } from "./selectors/node-selector";
import { LinkSelector } from "./selectors/link-selector";
import { ColorSelector } from "./selectors/color-selector";

import { TextButtons } from "./selectors/text-buttons";
import { slashCommand, suggestionItems } from "./slash-command";

const extensions = [...defaultExtensions, slashCommand];

const TailwindEditor = () => {
const [initialContent, setInitialContent] = useState<null | JSONContent>(
null,
);
const [saveStatus, setSaveStatus] = useState("Saved");

const [openNode, setOpenNode] = useState(false);
const [openColor, setOpenColor] = useState(false);
const [openLink, setOpenLink] = useState(false);

const debouncedUpdates = useDebouncedCallback(async (editor: Editor) => {
const json = editor.getJSON();

window.localStorage.setItem("novel-content", JSON.stringify(json));
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="absolute right-5 top-5 z-10 mb-5 rounded-lg bg-accent px-2 py-1 text-sm text-muted-foreground">
{saveStatus}
</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={{
...defaultEditorProps,
attributes: {
class: `prose-lg prose-stone 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] w-72 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>
{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>
))}
</EditorCommand>

<EditorBubble
tippyOptions={{
placement: "top",
}}
className="flex w-fit max-w-[90vw] overflow-hidden rounded border border-muted bg-background shadow-xl"
>
<Separator orientation="vertical" />
<NodeSelector open={openNode} onOpenChange={setOpenNode} />
<Separator orientation="vertical" />

<LinkSelector open={openLink} onOpenChange={setOpenLink} />
<Separator orientation="vertical" />
<TextButtons />
<Separator orientation="vertical" />
<ColorSelector open={openColor} onOpenChange={setOpenColor} />
</EditorBubble>
</EditorContent>
</EditorRoot>
</div>
);
};

export default TailwindEditor;
112 changes: 112 additions & 0 deletions apps/web/components/tailwind/extensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import {
TiptapImage,
TiptapLink,
UpdatedImage,
TaskList,
TaskItem,
HorizontalRule,
StarterKit,
Placeholder,
} from "novel/extensions";
import { UploadImagesPlugin } from "novel/plugins";

import { cx } from "class-variance-authority";
import { slashCommand } from "./slash-command";

//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

//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()];
},
}).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 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-sm bg-muted 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,
});

export const defaultExtensions = [
starterKit,
placeholder,
tiptapLink,
tiptapImage,
updatedImage,
taskList,
taskItem,
horizontalRule,
];
111 changes: 111 additions & 0 deletions apps/web/components/tailwind/selectors/ai-selector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"use client";

import {
Command,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/tailwind/ui/command";

import { useCompletion } from "ai/react";
import { toast } from "sonner";
import { useEditor } from "novel";
import { getPrevText } from "novel/extensions";
import { useState } from "react";

const options = [
{
value: "improve",
label: "Improve writing",
},
{
value: "continue",
label: "Continue writing",
},
{
value: "fix",
label: "Fix grammar",
},
{
value: "sorter",
label: "Make shorter",
},
{
value: "longer",
label: "Make longer",
},
];

//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({ open, onOpenChange }: AISelectorProps) {
const { editor } = useEditor();
const [extraPrompt, setExtraPrompt] = useState("");

const { completion, complete } = 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);
},
});

return (
<div
className="w-full"
onBlur={() => {
editor.chain().unsetHighlight().run();
}}
>
<Command>
{completion && (
<p className="w-full whitespace-pre-wrap p-2 px-4 text-sm">
{completion}
</p>
)}

<CommandInput
onFocus={() => {
editor.chain().setHighlight({ color: "#c1ecf970" }).run();
}}
value={extraPrompt}
onValueChange={setExtraPrompt}
autoFocus
placeholder="Ask AI to edit or generate..."
className="w-[400px]"
/>
<CommandGroup>
{options.map((option) => (
<CommandItem
className="px-4"
key={option.value}
value={option.value}
onSelect={(option) => {
if (option === "continue") {
getPrevText(editor, {
chars: 5000,
});
complete(editor.getText());
}
}}
>
{option.label}
</CommandItem>
))}
</CommandGroup>
</Command>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { Editor } from "@tiptap/core";
import { Check, ChevronDown } from "lucide-react";
import { Dispatch, FC, SetStateAction } from "react";
import * as Popover from "@radix-ui/react-popover";
import type { Dispatch, SetStateAction } from "react";
import { EditorBubbleItem, useEditor } from "novel";

import {
PopoverTrigger,
Popover,
PopoverContent,
} from "@/components/tailwind/ui/popover";
import { Button } from "@/components/tailwind/ui/button";
export interface BubbleColorMenuItem {
name: string;
color: string;
}

interface ColorSelectorProps {
editor: Editor;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
@@ -92,108 +96,104 @@ const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
},
];

export const ColorSelector: FC<ColorSelectorProps> = ({
editor,
isOpen,
setIsOpen,
}) => {
interface ColorSelectorProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}

export const ColorSelector = ({ open, onOpenChange }) => {
const { editor } = useEditor();

if (!editor) return null;
const activeColorItem = TEXT_COLORS.find(({ color }) =>
editor.isActive("textStyle", { color })
editor.isActive("textStyle", { color }),
);

const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) =>
editor.isActive("highlight", { color })
editor.isActive("highlight", { color }),
);

return (
<Popover.Root open={isOpen}>
<div className="novel-relative novel-h-full">
<Popover.Trigger
className="novel-flex novel-h-full novel-items-center novel-gap-1 novel-p-2 novel-text-sm novel-font-medium novel-text-stone-600 hover:novel-bg-stone-100 active:novel-bg-stone-200"
onClick={() => setIsOpen(!isOpen)}
>
<Popover modal={true} open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button className="gap-2 rounded-none" variant="ghost">
<span
className="novel-rounded-sm novel-px-1"
className="rounded-sm px-1"
style={{
color: activeColorItem?.color,
backgroundColor: activeHighlightItem?.color,
}}
>
A
</span>
<ChevronDown className="h-4 w-4" />
</Button>
</PopoverTrigger>

<ChevronDown className="novel-h-4 novel-w-4" />
</Popover.Trigger>

<Popover.Content
align="start"
className="novel-z-[99999] novel-my-1 novel-flex novel-max-h-80 novel-w-48 novel-flex-col novel-overflow-hidden novel-overflow-y-auto novel-rounded novel-border novel-border-stone-200 novel-bg-white novel-p-1 novel-shadow-xl novel-animate-in novel-fade-in novel-slide-in-from-top-1"
>
<div className="novel-my-1 novel-px-2 novel-text-sm novel-text-stone-500">
<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 }, index) => (
<button
<EditorBubbleItem
key={index}
onClick={() => {
onSelect={() => {
editor.commands.unsetColor();
name !== "Default" &&
editor
.chain()
.focus()
.setColor(color || "")
.run();
setIsOpen(false);
}}
className="novel-flex novel-items-center novel-justify-between novel-rounded-sm novel-px-2 novel-py-1 novel-text-sm novel-text-stone-600 hover:novel-bg-stone-100"
type="button"
className="flex cursor-pointer items-center justify-between px-2 py-1 text-sm hover:bg-accent"
>
<div className="novel-flex novel-items-center novel-space-x-2">
<div className="flex items-center gap-2">
<div
className="novel-rounded-sm novel-border novel-border-stone-200 novel-px-1 novel-py-px novel-font-medium"
className="rounded-sm border px-2 py-px font-medium"
style={{ color }}
>
A
</div>
<span>{name}</span>
</div>
{editor.isActive("textStyle", { color }) && (
<Check className="novel-h-4 novel-w-4" />
)}
</button>
</EditorBubbleItem>
))}

<div className="novel-mb-1 novel-mt-2 novel-px-2 novel-text-sm novel-text-stone-500">
</div>
<div>
<div className="my-1 px-2 text-sm font-semibold text-muted-foreground">
Background
</div>

{HIGHLIGHT_COLORS.map(({ name, color }, index) => (
<button
<EditorBubbleItem
key={index}
onClick={() => {
onSelect={() => {
editor.commands.unsetHighlight();
name !== "Default" && editor.commands.setHighlight({ color });
setIsOpen(false);
}}
className="novel-flex novel-items-center novel-justify-between novel-rounded-sm novel-px-2 novel-py-1 novel-text-sm novel-text-stone-600 hover:novel-bg-stone-100"
type="button"
className="flex cursor-pointer items-center justify-between px-2 py-1 text-sm hover:bg-accent"
>
<div className="novel-flex novel-items-center novel-space-x-2">
<div className="flex items-center gap-2">
<div
className="novel-rounded-sm novel-border novel-border-stone-200 novel-px-1 novel-py-px novel-font-medium"
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="novel-h-4 novel-w-4" />
<Check className="h-4 w-4" />
)}
</button>
</EditorBubbleItem>
))}
</Popover.Content>
</div>
</Popover.Root>
</div>
</PopoverContent>
</Popover>
);
};
101 changes: 101 additions & 0 deletions apps/web/components/tailwind/selectors/link-selector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { cn } from "@/lib/utils";
import { useEditor } from "novel";
import { Check, Trash } from "lucide-react";
import {
type Dispatch,
type FC,
type SetStateAction,
useEffect,
useRef,
} from "react";
import { Popover, PopoverTrigger } from "@radix-ui/react-popover";
import { Button } from "@/components/tailwind/ui/button";
import { PopoverContent } from "@/components/tailwind/ui/popover";

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 && inputRef.current?.focus();
});
if (!editor) return null;

return (
<Popover modal={true} open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button 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);
url && editor.chain().focus().setLink({ href: url }).run();
}}
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();
}}
>
<Trash className="h-4 w-4" />
</Button>
) : (
<Button size="icon" className="h-8">
<Check className="h-4 w-4" />
</Button>
)}
</form>
</PopoverContent>
</Popover>
);
};
145 changes: 145 additions & 0 deletions apps/web/components/tailwind/selectors/node-selector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import {
Check,
ChevronDown,
Heading1,
Heading2,
Heading3,
TextQuote,
ListOrdered,
TextIcon,
Code,
CheckSquare,
type LucideIcon,
} from "lucide-react";
import { EditorBubbleItem, useEditor } from "novel";

import { Popover } from "@radix-ui/react-popover";
import {
PopoverContent,
PopoverTrigger,
} from "@/components/tailwind/ui/popover";
import { Button } from "@/components/tailwind/ui/button";

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().toggleNode("paragraph", "paragraph").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().toggleHeading({ level: 1 }).run(),
isActive: (editor) => editor.isActive("heading", { level: 1 }),
},
{
name: "Heading 2",
icon: Heading2,
command: (editor) =>
editor.chain().focus().toggleHeading({ level: 2 }).run(),
isActive: (editor) => editor.isActive("heading", { level: 2 }),
},
{
name: "Heading 3",
icon: Heading3,
command: (editor) =>
editor.chain().focus().toggleHeading({ level: 3 }).run(),
isActive: (editor) => editor.isActive("heading", { level: 3 }),
},
{
name: "To-do List",
icon: CheckSquare,
command: (editor) => editor.chain().focus().toggleTaskList().run(),
isActive: (editor) => editor.isActive("taskItem"),
},
{
name: "Bullet List",
icon: ListOrdered,
command: (editor) => editor.chain().focus().toggleBulletList().run(),
isActive: (editor) => editor.isActive("bulletList"),
},
{
name: "Numbered List",
icon: ListOrdered,
command: (editor) => editor.chain().focus().toggleOrderedList().run(),
isActive: (editor) => editor.isActive("orderedList"),
},
{
name: "Quote",
icon: TextQuote,
command: (editor) =>
editor
.chain()
.focus()
.toggleNode("paragraph", "paragraph")
.toggleBlockquote()
.run(),
isActive: (editor) => editor.isActive("blockquote"),
},
{
name: "Code",
icon: Code,
command: (editor) => editor.chain().focus().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 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, index) => (
<EditorBubbleItem
key={index}
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>
);
};
68 changes: 68 additions & 0 deletions apps/web/components/tailwind/selectors/text-buttons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { cn } from "@/lib/utils";
import { EditorBubbleItem, useEditor } from "novel";
import {
BoldIcon,
ItalicIcon,
UnderlineIcon,
StrikethroughIcon,
CodeIcon,
} from "lucide-react";
import type { SelectorItem } from "./node-selector";
import { Button } from "@/components/tailwind/ui/button";

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, index) => (
<EditorBubbleItem
key={index}
onSelect={(editor) => {
item.command(editor);
}}
>
<Button size="icon" className="rounded-none" variant="ghost">
<item.icon
className={cn("h-4 w-4", {
"text-blue-500": item.isActive(editor),
})}
/>
</Button>
</EditorBubbleItem>
))}
</div>
);
};
161 changes: 161 additions & 0 deletions apps/web/components/tailwind/slash-command.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import {
CheckSquare,
Code,
Heading1,
Heading2,
Heading3,
ImageIcon,
List,
ListOrdered,
MessageSquarePlus,
Text,
TextQuote,
} from "lucide-react";
import { createSuggestionItems } from "novel/extensions";
import { startImageUpload } from "novel/plugins";
import { Command, renderItems } from "novel/extensions";

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;
startImageUpload(file, editor.view, pos);
}
};
input.click();
},
},
]);

export const slashCommand = Command.configure({
suggestion: {
items: () => suggestionItems,
render: renderItems,
},
});
56 changes: 56 additions & 0 deletions apps/web/components/tailwind/ui/button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
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 }
155 changes: 155 additions & 0 deletions apps/web/components/tailwind/ui/command.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"use client";

import * as React from "react";
import { type DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";

import { cn } from "@/lib/utils";
import { Dialog, DialogContent } from "@/components/tailwind/ui/dialog";
import Magic from "@/components/tailwind/ui/icons/magic";

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-3" cmdk-input-wrapper="">
<Magic className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<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]:pointer-events-none data-[disabled]: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,
};
122 changes: 122 additions & 0 deletions apps/web/components/tailwind/ui/dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"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 renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -3,8 +3,8 @@ export default function LoadingCircle({ dimensions }: { dimensions?: string }) {
<svg
aria-hidden="true"
className={`${
dimensions || "novel-h-4 novel-w-4"
} novel-animate-spin novel-fill-stone-600 novel-text-stone-200`}
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"
File renamed without changes.
64 changes: 33 additions & 31 deletions apps/web/ui/menu.tsx → apps/web/components/tailwind/ui/menu.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
"use client";

import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/ui/primitives/popover";
// import { useContext } from "react";
// import { AppContext } from "../app/providers";
// import { FontDefault, FontSerif, FontMono } from "@/ui/icons";
import { Check, Menu as MenuIcon, Monitor, Moon, SunDim } from "lucide-react";
import { useTheme } from "next-themes";
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
import { cn } from "@/lib/utils";
import { cva } from "class-variance-authority";
import { Button } from "./button";

// const fonts = [
// {
@@ -39,17 +38,21 @@ const appearances = [
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 className="absolute bottom-5 right-5 z-10 flex h-8 w-8 items-center justify-center rounded-lg transition-colors duration-200 hover:bg-stone-100 active:bg-stone-200 sm:bottom-auto sm:top-5">
<MenuIcon className="text-stone-600" width={16} />
<PopoverTrigger
asChild
className="absolute bottom-5 right-5 z-10 flex h-8 w-8 items-center justify-center rounded-lg transition-colors duration-200 sm:bottom-auto sm:top-5"
>
<Button variant="ghost" size="icon">
<MenuIcon width={16} />
</Button>
</PopoverTrigger>
<PopoverContent className="w-52 divide-y divide-stone-200" align="end">
<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 }) => (
@@ -70,28 +73,27 @@ export default function Menu() {
</button>
))}
</div> */}
<div className="p-2">
<p className="p-2 text-xs font-medium text-stone-500">Appearance</p>
{appearances.map(({ theme, icon }) => (
<button
key={theme}
className="flex w-full items-center justify-between rounded px-2 py-1.5 text-sm text-stone-600 hover:bg-stone-100"
onClick={() => {
setTheme(theme.toLowerCase());
}}
>
<div className="flex items-center space-x-2">
<div className="rounded-sm border border-stone-200 p-1">
{icon}
</div>
<span>{theme}</span>
</div>
{currentTheme === theme.toLowerCase() && (
<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>
);
Loading