diff --git a/packages/editor/package.json b/packages/editor/package.json
new file mode 100644
index 000000000..218615591
--- /dev/null
+++ b/packages/editor/package.json
@@ -0,0 +1,85 @@
+{
+ "name": "plane-editor",
+ "version": "0.0.1",
+ "description": "Rich Text Editor that powers Plane",
+ "main": "./dist/index.js",
+ "module": "./dist/index.mjs",
+ "types": "./dist/index.d.ts",
+ "files": [
+ "dist/**/*"
+ ],
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.mjs",
+ "module": "./dist/index.mjs",
+ "require": "./dist/index.js"
+ }
+ },
+ "scripts": {
+ "build": "tsup",
+ "dev": "tsup --watch",
+ "check-types": "tsc --noEmit"
+ },
+ "peerDependencies": {
+ "react": "^18.2.0"
+ },
+ "dependencies": {
+ "@blueprintjs/popover2": "^2.0.10",
+ "@tiptap/core": "^2.1.7",
+ "@tiptap/extension-code-block-lowlight": "^2.0.4",
+ "@tiptap/extension-highlight": "^2.1.7",
+ "@tiptap/extension-horizontal-rule": "^2.1.7",
+ "@tiptap/extension-image": "^2.1.7",
+ "@tiptap/extension-link": "^2.1.7",
+ "@tiptap/extension-placeholder": "2.0.3",
+ "@tiptap/extension-table": "^2.1.6",
+ "@tiptap/extension-table-cell": "^2.1.6",
+ "@tiptap/extension-table-header": "^2.1.6",
+ "@tiptap/extension-table-row": "^2.1.6",
+ "@tiptap/extension-task-item": "^2.1.7",
+ "@tiptap/extension-task-list": "^2.1.7",
+ "@tiptap/extension-text-style": "^2.1.7",
+ "@tiptap/extension-underline": "^2.1.7",
+ "@tiptap/pm": "^2.1.7",
+ "@tiptap/react": "^2.1.7",
+ "@tiptap/starter-kit": "^2.1.7",
+ "@tiptap/suggestion": "^2.1.7",
+ "@types/node": "18.15.3",
+ "@types/react": "18.0.28",
+ "@types/react-dom": "18.0.11",
+ "clsx": "^1.2.1",
+ "eslint": "8.36.0",
+ "eslint-config-next": "13.2.4",
+ "eventsource-parser": "^0.1.0",
+ "lowlight": "^2.9.0",
+ "lucide-react": "^0.244.0",
+ "next": "12.3.2",
+ "next-themes": "^0.2.1",
+ "react": "18.2.0",
+ "react-dom": "18.2.0",
+ "react-markdown": "^8.0.7",
+ "sonner": "^0.7.0",
+ "tailwind-merge": "^1.14.0",
+ "tippy.js": "^6.3.7",
+ "tiptap-markdown": "^0.8.2",
+ "use-debounce": "^9.0.4"
+ },
+ "devDependencies": {
+ "@types/react": "^18.2.5",
+ "eslint": "^7.32.0",
+ "postcss": "^8.4.29",
+ "react": "^18.2.0",
+ "tailwind-config": "*",
+ "tsconfig": "*",
+ "tsup": "^7.2.0",
+ "typescript": "4.9.5"
+ },
+ "keywords": [
+ "editor",
+ "rich-text",
+ "markdown",
+ "nextjs",
+ "react"
+ ]
+}
diff --git a/packages/editor/postcss.config.js b/packages/editor/postcss.config.js
new file mode 100644
index 000000000..07aa434b2
--- /dev/null
+++ b/packages/editor/postcss.config.js
@@ -0,0 +1,9 @@
+// If you want to use other PostCSS plugins, see the following:
+// https://tailwindcss.com/docs/using-with-preprocessors
+
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/packages/editor/src/README.md b/packages/editor/src/README.md
new file mode 100644
index 000000000..aa673e39f
--- /dev/null
+++ b/packages/editor/src/README.md
@@ -0,0 +1,139 @@
+
+
+ Plane's Editor
+
+
+
+ An open-source Notion-style WYSIWYG editor with AI-powered autocompletions.
+
+
+
+
+
+
+
+
+
+
+
+ Introduction ·
+ Installation ·
+ Deploy Your Own ·
+ Setting Up Locally ·
+ Tech Stack ·
+ Contributing ·
+ License
+
+
+
+## Introduction
+
+[Novel](https://novel.sh/) is a Notion-style WYSIWYG editor with AI-powered autocompletions.
+
+https://github.com/steven-tey/novel/assets/28986134/2099877f-4f2b-4b1c-8782-5d803d63be5c
+
+
+
+## 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):
+
+```
+npm i novel
+```
+
+Then, you can use it in your code like this:
+
+```jsx
+import { Editor } from "novel";
+
+export default function App() {
+ return ;
+}
+```
+
+The `Editor` is a React component that takes in the following props:
+
+| Prop | Type | Description | Default |
+| ------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
+| `completionApi` | `string` | The API route to use for the OpenAI completion API. | `/api/generate` |
+| `className` | `string` | Editor container classname. | `"relative min-h-[500px] w-full max-w-screen-lg border-stone-200 bg-white sm:mb-[calc(20vh)] sm:rounded-lg sm:border sm:shadow-lg"` |
+| `defaultValue` | `JSONContent` or `string` | The default value to use for the editor. | [`defaultEditorContent`](https://github.com/steven-tey/novel/blob/main/packages/core/src/ui/editor/default-content.tsx) |
+| `extensions` | `Extension[]` | A list of extensions to use for the editor, in addition to the [default Novel extensions](https://github.com/steven-tey/novel/blob/main/packages/core/src/ui/editor/extensions/index.tsx). | `[]` |
+| `editorProps` | `EditorProps` | Props to pass to the underlying Tiptap editor, in addition to the [default Novel editor props](https://github.com/steven-tey/novel/blob/main/packages/core/src/ui/editor/props.ts). | `{}` |
+| `onUpdate` | `(editor?: Editor) => void` | A callback function that is called whenever the editor is updated. | `() => {}` |
+| `onDebouncedUpdate` | `(editor?: Editor) => void` | A callback function that is called whenever the editor is updated, but only after the defined debounce duration. | `() => {}` |
+| `debounceDuration` | `number` | The duration (in milliseconds) to debounce the `onDebouncedUpdate` callback. | `750` |
+| `storageKey` | `string` | The key to use for storing the editor's value in local storage. | `novel__content` |
+
+> **Note**: Make sure to define an API endpoint that matches the `completionApi` prop (default is `/api/generate`). This is needed for the AI autocompletions to work. Here's an example: https://github.com/steven-tey/novel/blob/main/apps/web/app/api/generate/route.ts
+
+Here's an example application: https://github.com/steven-tey/novella
+
+## Deploy Your Own
+
+You can deploy your own version of Novel to Vercel with one click:
+
+[![Deploy with Vercel](https://vercel.com/button)](https://stey.me/novel-deploy)
+
+## Setting Up Locally
+
+To set up Novel locally, you'll need to clone the repository and set up the following environment variables:
+
+- `OPENAI_API_KEY` – your OpenAI API key (you can get one [here](https://platform.openai.com/account/api-keys))
+- `BLOB_READ_WRITE_TOKEN` – your Vercel Blob read/write token (currently [still in beta](https://vercel.com/docs/storage/vercel-blob/quickstart#quickstart), but feel free to [sign up on this form](https://vercel.fyi/blob-beta) for access)
+
+If you've deployed this to Vercel, you can also use [`vc env pull`](https://vercel.com/docs/cli/env#exporting-development-environment-variables) to pull the environment variables from your Vercel project.
+
+To run the app locally, you can run the following commands:
+
+```
+pnpm i
+pnpm build
+pnpm dev
+```
+
+## Cross-framework support
+
+While Novel is built for React, we also have a few community-maintained packages for non-React frameworks:
+
+- Svelte: https://novel.sh/svelte
+- Vue: https://novel.sh/vue
+
+## VSCode Extension
+
+Thanks to @bennykok, Novel also has a VSCode Extension: https://novel.sh/vscode
+
+https://github.com/steven-tey/novel/assets/28986134/58ebf7e3-cdb3-43df-878b-119e304f7373
+
+## Tech Stack
+
+Novel is built on the following stack:
+
+- [Next.js](https://nextjs.org/) – framework
+- [Tiptap](https://tiptap.dev/) – text editor
+- [OpenAI](https://openai.com/) - AI completions
+- [Vercel AI SDK](https://sdk.vercel.ai/docs) – AI library
+- [Vercel](https://vercel.com) – deployments
+- [TailwindCSS](https://tailwindcss.com/) – styles
+- [Cal Sans](https://github.com/calcom/font) – font
+
+## Contributing
+
+Here's how you can contribute:
+
+- [Open an issue](https://github.com/steven-tey/novel/issues) if you believe you've encountered a bug.
+- Make a [pull request](https://github.com/steven-tey/novel/pull) to add new features/make quality-of-life improvements/fix bugs.
+
+
+
+
+
+## Repo Activity
+
+![Novel.sh repo activity – generated by Axiom](https://repobeats.axiom.co/api/embed/2ebdaa143b0ad6e7c2ee23151da7b37f67da0b36.svg)
+
+## License
+
+Licensed under the [Apache-2.0 license](https://github.com/steven-tey/novel/blob/main/LICENSE.md).
+
diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx
new file mode 100644
index 000000000..1a692d8d0
--- /dev/null
+++ b/packages/editor/src/index.tsx
@@ -0,0 +1,106 @@
+import * as React from 'react';
+import { useImperativeHandle, useRef, forwardRef } from "react";
+import { useEditor, EditorContent, Editor } from "@tiptap/react";
+import { useDebouncedCallback } from "use-debounce";
+import { TableMenu } from '@/ui/editor/menus/table-menu';
+import { TiptapExtensions } from '@/ui/editor/extensions';
+
+export interface ITipTapRichTextEditor {
+ value: string;
+ noBorder?: boolean;
+ borderOnFocus?: boolean;
+ customClassName?: string;
+ editorContentCustomClassNames?: string;
+ onChange?: (json: any, html: string) => void;
+ setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
+ setShouldShowAlert?: (showAlert: boolean) => void;
+ workspaceSlug: string;
+ editable?: boolean;
+ forwardedRef?: any;
+ debouncedUpdatesEnabled?: boolean;
+}
+
+const Tiptap = (props: ITipTapRichTextEditor) => {
+ const {
+ onChange,
+ debouncedUpdatesEnabled,
+ forwardedRef,
+ editable,
+ setIsSubmitting,
+ setShouldShowAlert,
+ editorContentCustomClassNames,
+ value,
+ noBorder,
+ workspaceSlug,
+ borderOnFocus,
+ customClassName,
+ } = props;
+
+ const editor = useEditor({
+ editable: editable ?? true,
+ editorProps: TiptapEditorProps(workspaceSlug, setIsSubmitting),
+ extensions: TiptapExtensions(workspaceSlug, setIsSubmitting),
+ content: value,
+ onUpdate: async ({ editor }) => {
+ // for instant feedback loop
+ setIsSubmitting?.("submitting");
+ setShouldShowAlert?.(true);
+ if (debouncedUpdatesEnabled) {
+ debouncedUpdates({ onChange, editor });
+ } else {
+ onChange?.(editor.getJSON(), editor.getHTML());
+ }
+ },
+ });
+
+ const editorRef: React.MutableRefObject = useRef(null);
+
+ useImperativeHandle(forwardedRef, () => ({
+ clearEditor: () => {
+ editorRef.current?.commands.clearContent();
+ },
+ setEditorValue: (content: string) => {
+ editorRef.current?.commands.setContent(content);
+ },
+ }));
+
+ const debouncedUpdates = useDebouncedCallback(async ({ onChange, editor }) => {
+ setTimeout(async () => {
+ if (onChange) {
+ onChange(editor.getJSON(), editor.getHTML());
+ }
+ }, 500);
+ }, 1000);
+
+ const editorClassNames = `relative w-full max-w-full sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md
+ ${noBorder ? "" : "border border-custom-border-200"} ${borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0"
+ } ${customClassName}`;
+
+ if (!editor) return null;
+ editorRef.current = editor;
+
+ return (
+ {
+ editor?.chain().focus().run();
+ }}
+ className={`tiptap-editor-container cursor-text ${editorClassNames}`}
+ >
+ {editor &&
}
+
+
+
+ {editor?.isActive("image") &&
}
+
+
+ );
+};
+
+const TipTapEditor = forwardRef((props, ref) => (
+
+));
+
+TipTapEditor.displayName = "TipTapEditor";
+
+export { TipTapEditor };
diff --git a/packages/editor/src/lib/utils.ts b/packages/editor/src/lib/utils.ts
new file mode 100644
index 000000000..a5ef19350
--- /dev/null
+++ b/packages/editor/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/packages/editor/src/style/editor.css b/packages/editor/src/style/editor.css
new file mode 100644
index 000000000..9da250dd1
--- /dev/null
+++ b/packages/editor/src/style/editor.css
@@ -0,0 +1,231 @@
+.ProseMirror p.is-editor-empty:first-child::before {
+ content: attr(data-placeholder);
+ float: left;
+ color: rgb(var(--color-text-400));
+ pointer-events: none;
+ height: 0;
+}
+
+.ProseMirror .is-empty::before {
+ content: attr(data-placeholder);
+ float: left;
+ color: rgb(var(--color-text-400));
+ 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%);
+ }
+}
+
+.ProseMirror-gapcursor:after {
+ border-top: 1px solid rgb(var(--color-text-100)) !important;
+}
+
+/* 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: rgb(var(--color-background-100));
+ margin: 0;
+ cursor: pointer;
+ width: 1.2rem;
+ height: 1.2rem;
+ position: relative;
+ border: 2px solid rgb(var(--color-text-100));
+ margin-right: 0.3rem;
+ display: grid;
+ place-content: center;
+
+ &:hover {
+ background-color: rgb(var(--color-background-80));
+ }
+
+ &:active {
+ background-color: rgb(var(--color-background-90));
+ }
+
+ &::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: rgb(var(--color-text-200));
+ text-decoration: line-through;
+ text-decoration-thickness: 2px;
+}
+
+/* Overwrite tippy-box original max-width */
+
+.tippy-box {
+ max-width: 400px !important;
+}
+
+.ProseMirror {
+ position: relative;
+ word-wrap: break-word;
+ white-space: pre-wrap;
+ -moz-tab-size: 4;
+ tab-size: 4;
+ -webkit-user-select: text;
+ -moz-user-select: text;
+ -ms-user-select: text;
+ user-select: text;
+ outline: none;
+ cursor: text;
+ line-height: 1.2;
+ font-family: inherit;
+ font-size: 14px;
+ color: inherit;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+ appearance: textfield;
+ -webkit-appearance: textfield;
+ -moz-appearance: textfield;
+}
+
+.fadeIn {
+ opacity: 1;
+ transition: opacity 0.3s ease-in;
+}
+
+.fadeOut {
+ opacity: 0;
+ transition: opacity 0.2s ease-out;
+}
+
+.img-placeholder {
+ position: relative;
+ width: 35%;
+
+ &:before {
+ content: "";
+ box-sizing: border-box;
+ position: absolute;
+ top: 50%;
+ left: 45%;
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ border: 3px solid rgba(var(--color-text-200));
+ border-top-color: rgba(var(--color-text-800));
+ animation: spinning 0.6s linear infinite;
+ }
+}
+
+@keyframes spinning {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+#tiptap-container {
+ table {
+ border-collapse: collapse;
+ table-layout: fixed;
+ margin: 0;
+ border: 1px solid rgb(var(--color-border-200));
+ width: 100%;
+
+ td,
+ th {
+ min-width: 1em;
+ border: 1px solid rgb(var(--color-border-200));
+ padding: 10px 15px;
+ vertical-align: top;
+ box-sizing: border-box;
+ position: relative;
+ transition: background-color 0.3s ease;
+
+ > * {
+ margin-bottom: 0;
+ }
+ }
+
+ th {
+ font-weight: bold;
+ text-align: left;
+ background-color: rgb(var(--color-primary-100));
+ }
+
+ td:hover {
+ background-color: rgba(var(--color-primary-300), 0.1);
+ }
+
+ .selectedCell:after {
+ z-index: 2;
+ position: absolute;
+ content: "";
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ background-color: rgba(var(--color-primary-300), 0.1);
+ pointer-events: none;
+ }
+
+ .column-resize-handle {
+ position: absolute;
+ right: -2px;
+ top: 0;
+ bottom: -2px;
+ width: 2px;
+ background-color: rgb(var(--color-primary-400));
+ pointer-events: none;
+ }
+ }
+}
+
+.tableWrapper {
+ overflow-x: auto;
+}
+
+.resize-cursor {
+ cursor: ew-resize;
+ cursor: col-resize;
+}
+
+.ProseMirror table * p {
+ padding: 0px 1px;
+ margin: 6px 2px;
+}
+
+.ProseMirror table * .is-empty::before {
+ opacity: 0;
+}
diff --git a/packages/editor/src/ui/editor/extensions/image/image-resize.tsx b/packages/editor/src/ui/editor/extensions/image/image-resize.tsx
new file mode 100644
index 000000000..448b8811c
--- /dev/null
+++ b/packages/editor/src/ui/editor/extensions/image/image-resize.tsx
@@ -0,0 +1,44 @@
+import { Editor } from "@tiptap/react";
+import Moveable from "react-moveable";
+
+export const ImageResizer = ({ editor }: { editor: Editor }) => {
+ const updateMediaSize = () => {
+ const imageInfo = document.querySelector(".ProseMirror-selectednode") as HTMLImageElement;
+ if (imageInfo) {
+ const selection = editor.state.selection;
+ editor.commands.setImage({
+ src: imageInfo.src,
+ width: Number(imageInfo.style.width.replace("px", "")),
+ height: Number(imageInfo.style.height.replace("px", "")),
+ } as any);
+ editor.commands.setNodeSelection(selection.from);
+ }
+ };
+
+ return (
+ <>
+ {
+ delta[0] && (target!.style.width = `${width}px`);
+ delta[1] && (target!.style.height = `${height}px`);
+ }}
+ onResizeEnd={() => {
+ updateMediaSize();
+ }}
+ scalable={true}
+ renderDirections={["w", "e"]}
+ onScale={({ target, transform }: any) => {
+ target!.style.transform = transform;
+ }}
+ />
+ >
+ );
+};
diff --git a/packages/editor/src/ui/editor/extensions/image/updated-image.tsx b/packages/editor/src/ui/editor/extensions/image/updated-image.tsx
new file mode 100644
index 000000000..b62050953
--- /dev/null
+++ b/packages/editor/src/ui/editor/extensions/image/updated-image.tsx
@@ -0,0 +1,22 @@
+import Image from "@tiptap/extension-image";
+import TrackImageDeletionPlugin from "../plugins/delete-image";
+import UploadImagesPlugin from "../plugins/upload-image";
+
+const UpdatedImage = Image.extend({
+ addProseMirrorPlugins() {
+ return [UploadImagesPlugin(), TrackImageDeletionPlugin()];
+ },
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+ width: {
+ default: "35%",
+ },
+ height: {
+ default: null,
+ },
+ };
+ },
+});
+
+export default UpdatedImage;
diff --git a/packages/editor/src/ui/editor/extensions/index.tsx b/packages/editor/src/ui/editor/extensions/index.tsx
new file mode 100644
index 000000000..e13d83a09
--- /dev/null
+++ b/packages/editor/src/ui/editor/extensions/index.tsx
@@ -0,0 +1,150 @@
+import StarterKit from "@tiptap/starter-kit";
+import HorizontalRule from "@tiptap/extension-horizontal-rule";
+import TiptapLink from "@tiptap/extension-link";
+import Placeholder from "@tiptap/extension-placeholder";
+import TiptapUnderline from "@tiptap/extension-underline";
+import TextStyle from "@tiptap/extension-text-style";
+import { Color } from "@tiptap/extension-color";
+import TaskItem from "@tiptap/extension-task-item";
+import TaskList from "@tiptap/extension-task-list";
+import { Markdown } from "tiptap-markdown";
+import Highlight from "@tiptap/extension-highlight";
+import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
+import { lowlight } from "lowlight/lib/core";
+import { InputRule } from "@tiptap/core";
+import Gapcursor from "@tiptap/extension-gapcursor";
+
+import ts from "highlight.js/lib/languages/typescript";
+
+import "highlight.js/styles/github-dark.css";
+import UniqueID from "@tiptap-pro/extension-unique-id";
+import { CustomTableCell } from "./table/table-cell";
+import { Table } from "./table/table";
+import { TableHeader } from "./table/table-header";
+import { TableRow } from "@tiptap/extension-table-row";
+
+lowlight.registerLanguage("ts", ts);
+
+export const TiptapExtensions = (
+ workspaceSlug: string,
+ setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
+) => [
+ StarterKit.configure({
+ bulletList: {
+ HTMLAttributes: {
+ class: "list-disc list-outside leading-3 -mt-2",
+ },
+ },
+ orderedList: {
+ HTMLAttributes: {
+ class: "list-decimal list-outside leading-3 -mt-2",
+ },
+ },
+ listItem: {
+ HTMLAttributes: {
+ class: "leading-normal -mb-2",
+ },
+ },
+ blockquote: {
+ HTMLAttributes: {
+ class: "border-l-4 border-custom-border-300",
+ },
+ },
+ code: {
+ HTMLAttributes: {
+ class:
+ "rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
+ spellcheck: "false",
+ },
+ },
+ codeBlock: false,
+ horizontalRule: false,
+ dropcursor: {
+ color: "rgba(var(--color-text-100))",
+ width: 2,
+ },
+ gapcursor: false,
+ }),
+ CodeBlockLowlight.configure({
+ lowlight,
+ }),
+ HorizontalRule.extend({
+ addInputRules() {
+ return [
+ new InputRule({
+ find: /^(?:---|—-|___\s|\*\*\*\s)$/,
+ handler: ({ state, range, commands }) => {
+ commands.splitBlock();
+
+ const attributes = {};
+ const { tr } = state;
+ const start = range.from;
+ const end = range.to;
+ // @ts-ignore
+ tr.replaceWith(start - 1, end, this.type.create(attributes));
+ },
+ }),
+ ];
+ },
+ }).configure({
+ HTMLAttributes: {
+ class: "mb-6 border-t border-custom-border-300",
+ },
+ }),
+ Gapcursor,
+ TiptapLink.configure({
+ protocols: ["http", "https"],
+ validate: (url) => isValidHttpUrl(url),
+ HTMLAttributes: {
+ class:
+ "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
+ },
+ }),
+ UpdatedImage.configure({
+ HTMLAttributes: {
+ class: "rounded-lg border border-custom-border-300",
+ },
+ }),
+ Placeholder.configure({
+ placeholder: ({ node }) => {
+ if (node.type.name === "heading") {
+ return `Heading ${node.attrs.level}`;
+ }
+ if (node.type.name === "image" || node.type.name === "table") {
+ return "";
+ }
+
+ return "Press '/' for commands...";
+ },
+ includeChildren: true,
+ }),
+ UniqueID.configure({
+ types: ["image"],
+ }),
+ SlashCommand(workspaceSlug, setIsSubmitting),
+ TiptapUnderline,
+ TextStyle,
+ Color,
+ Highlight.configure({
+ multicolor: true,
+ }),
+ TaskList.configure({
+ HTMLAttributes: {
+ class: "not-prose pl-2",
+ },
+ }),
+ TaskItem.configure({
+ HTMLAttributes: {
+ class: "flex items-start my-4",
+ },
+ nested: true,
+ }),
+ Markdown.configure({
+ html: true,
+ transformCopiedText: true,
+ }),
+ Table,
+ TableHeader,
+ CustomTableCell,
+ TableRow,
+ ];
diff --git a/packages/editor/src/ui/editor/extensions/slash-command.tsx b/packages/editor/src/ui/editor/extensions/slash-command.tsx
new file mode 100644
index 000000000..9dd4d8f93
--- /dev/null
+++ b/packages/editor/src/ui/editor/extensions/slash-command.tsx
@@ -0,0 +1,365 @@
+import { useState, useEffect, useCallback, ReactNode, useRef, useLayoutEffect } from "react";
+import { Editor, Range, Extension } from "@tiptap/core";
+import Suggestion from "@tiptap/suggestion";
+import { ReactRenderer } from "@tiptap/react";
+import tippy from "tippy.js";
+import {
+ Heading1,
+ Heading2,
+ Heading3,
+ List,
+ ListOrdered,
+ Text,
+ TextQuote,
+ Code,
+ MinusSquare,
+ CheckSquare,
+ ImageIcon,
+ Table,
+} from "lucide-react";
+import { startImageUpload } from "../plugins/upload-image";
+import { cn } from "../utils";
+
+interface CommandItemProps {
+ title: string;
+ description: string;
+ icon: ReactNode;
+}
+
+interface CommandProps {
+ editor: Editor;
+ range: Range;
+}
+
+const Command = Extension.create({
+ name: "slash-command",
+ addOptions() {
+ return {
+ suggestion: {
+ char: "/",
+ command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
+ props.command({ editor, range });
+ },
+ },
+ };
+ },
+ addProseMirrorPlugins() {
+ return [
+ Suggestion({
+ editor: this.editor,
+ allow({ editor }) {
+ return !editor.isActive("table");
+ },
+ ...this.options.suggestion,
+ }),
+ ];
+ },
+});
+
+const getSuggestionItems =
+ (
+ workspaceSlug: string,
+ setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
+ ) =>
+ ({ query }: { query: string }) =>
+ [
+ {
+ title: "Text",
+ description: "Just start typing with plain text.",
+ searchTerms: ["p", "paragraph"],
+ icon: ,
+ command: ({ editor, range }: CommandProps) => {
+ editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").run();
+ },
+ },
+ {
+ title: "Heading 1",
+ description: "Big section heading.",
+ searchTerms: ["title", "big", "large"],
+ icon: ,
+ command: ({ editor, range }: CommandProps) => {
+ editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
+ },
+ },
+ {
+ title: "Heading 2",
+ description: "Medium section heading.",
+ searchTerms: ["subtitle", "medium"],
+ icon: ,
+ command: ({ editor, range }: CommandProps) => {
+ editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
+ },
+ },
+ {
+ title: "Heading 3",
+ description: "Small section heading.",
+ searchTerms: ["subtitle", "small"],
+ icon: ,
+ command: ({ editor, range }: CommandProps) => {
+ editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
+ },
+ },
+ {
+ title: "To-do List",
+ description: "Track tasks with a to-do list.",
+ searchTerms: ["todo", "task", "list", "check", "checkbox"],
+ icon: ,
+ command: ({ editor, range }: CommandProps) => {
+ editor.chain().focus().deleteRange(range).toggleTaskList().run();
+ },
+ },
+ {
+ title: "Bullet List",
+ description: "Create a simple bullet list.",
+ searchTerms: ["unordered", "point"],
+ icon:
,
+ command: ({ editor, range }: CommandProps) => {
+ editor.chain().focus().deleteRange(range).toggleBulletList().run();
+ },
+ },
+ {
+ title: "Divider",
+ description: "Visually divide blocks",
+ searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
+ icon: ,
+ command: ({ editor, range }: CommandProps) => {
+ editor.chain().focus().deleteRange(range).setHorizontalRule().run();
+ },
+ },
+ {
+ title: "Table",
+ description: "Create a Table",
+ searchTerms: ["table", "cell", "db", "data", "tabular"],
+ icon: ,
+ command: ({ editor, range }: CommandProps) => {
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
+ .run();
+ },
+ },
+ {
+ title: "Numbered List",
+ description: "Create a list with numbering.",
+ searchTerms: ["ordered"],
+ icon: ,
+ command: ({ editor, range }: CommandProps) => {
+ editor.chain().focus().deleteRange(range).toggleOrderedList().run();
+ },
+ },
+ {
+ title: "Quote",
+ description: "Capture a quote.",
+ searchTerms: ["blockquote"],
+ icon: ,
+ command: ({ editor, range }: CommandProps) =>
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .toggleNode("paragraph", "paragraph")
+ .toggleBlockquote()
+ .run(),
+ },
+ {
+ title: "Code",
+ description: "Capture a code snippet.",
+ searchTerms: ["codeblock"],
+ icon:
,
+ command: ({ editor, range }: CommandProps) =>
+ editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
+ },
+ {
+ title: "Image",
+ description: "Upload an image from your computer.",
+ searchTerms: ["photo", "picture", "media"],
+ icon: ,
+ command: ({ editor, range }: CommandProps) => {
+ 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, workspaceSlug, setIsSubmitting);
+ }
+ };
+ input.click();
+ },
+ },
+ ].filter((item) => {
+ if (typeof query === "string" && query.length > 0) {
+ const search = query.toLowerCase();
+ return (
+ item.title.toLowerCase().includes(search) ||
+ item.description.toLowerCase().includes(search) ||
+ (item.searchTerms && item.searchTerms.some((term: string) => term.includes(search)))
+ );
+ }
+ return true;
+ });
+
+export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
+ const containerHeight = container.offsetHeight;
+ const itemHeight = item ? item.offsetHeight : 0;
+
+ const top = item.offsetTop;
+ const bottom = top + itemHeight;
+
+ if (top < container.scrollTop) {
+ container.scrollTop -= container.scrollTop - top + 5;
+ } else if (bottom > containerHeight + container.scrollTop) {
+ container.scrollTop += bottom - containerHeight - container.scrollTop + 5;
+ }
+};
+
+const CommandList = ({
+ items,
+ command,
+}: {
+ items: CommandItemProps[];
+ command: any;
+ editor: any;
+ range: any;
+}) => {
+ const [selectedIndex, setSelectedIndex] = useState(0);
+
+ const selectItem = useCallback(
+ (index: number) => {
+ const item = items[index];
+ if (item) {
+ command(item);
+ }
+ },
+ [command, items]
+ );
+
+ useEffect(() => {
+ const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (navigationKeys.includes(e.key)) {
+ e.preventDefault();
+ if (e.key === "ArrowUp") {
+ setSelectedIndex((selectedIndex + items.length - 1) % items.length);
+ return true;
+ }
+ if (e.key === "ArrowDown") {
+ setSelectedIndex((selectedIndex + 1) % items.length);
+ return true;
+ }
+ if (e.key === "Enter") {
+ selectItem(selectedIndex);
+ return true;
+ }
+ return false;
+ }
+ };
+ document.addEventListener("keydown", onKeyDown);
+ return () => {
+ document.removeEventListener("keydown", onKeyDown);
+ };
+ }, [items, selectedIndex, setSelectedIndex, selectItem]);
+
+ useEffect(() => {
+ setSelectedIndex(0);
+ }, [items]);
+
+ const commandListContainer = useRef(null);
+
+ useLayoutEffect(() => {
+ const container = commandListContainer?.current;
+
+ const item = container?.children[selectedIndex] as HTMLElement;
+
+ if (item && container) updateScrollView(container, item);
+ }, [selectedIndex]);
+
+ return items.length > 0 ? (
+
+ {items.map((item: CommandItemProps, index: number) => (
+
selectItem(index)}
+ >
+
+
{item.title}
+
{item.description}
+
+
+ ))}
+
+ ) : null;
+};
+
+const renderItems = () => {
+ let component: ReactRenderer | null = null;
+ let popup: any | null = null;
+
+ return {
+ onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
+ component = new ReactRenderer(CommandList, {
+ props,
+ editor: props.editor,
+ });
+
+ // @ts-ignore
+ popup = tippy("body", {
+ getReferenceClientRect: props.clientRect,
+ appendTo: () => document.querySelector("#tiptap-container"),
+ content: component.element,
+ showOnCreate: true,
+ interactive: true,
+ trigger: "manual",
+ placement: "bottom-start",
+ });
+ },
+ onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
+ component?.updateProps(props);
+
+ popup &&
+ popup[0].setProps({
+ getReferenceClientRect: props.clientRect,
+ });
+ },
+ onKeyDown: (props: { event: KeyboardEvent }) => {
+ if (props.event.key === "Escape") {
+ popup?.[0].hide();
+
+ return true;
+ }
+
+ // @ts-ignore
+ return component?.ref?.onKeyDown(props);
+ },
+ onExit: () => {
+ popup?.[0].destroy();
+ component?.destroy();
+ },
+ };
+};
+
+export const SlashCommand = (
+ workspaceSlug: string,
+ setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
+) =>
+ Command.configure({
+ suggestion: {
+ items: getSuggestionItems(workspaceSlug, setIsSubmitting),
+ render: renderItems,
+ },
+ });
+
+export default SlashCommand;
diff --git a/packages/editor/src/ui/editor/extensions/table/table-cell.ts b/packages/editor/src/ui/editor/extensions/table/table-cell.ts
new file mode 100644
index 000000000..643cb8c64
--- /dev/null
+++ b/packages/editor/src/ui/editor/extensions/table/table-cell.ts
@@ -0,0 +1,32 @@
+import { TableCell } from "@tiptap/extension-table-cell";
+
+export const CustomTableCell = TableCell.extend({
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+ isHeader: {
+ default: false,
+ parseHTML: (element) => {
+ isHeader: element.tagName === "TD";
+ },
+ renderHTML: (attributes) => {
+ tag: attributes.isHeader ? "th" : "td";
+ },
+ },
+ };
+ },
+ renderHTML({ HTMLAttributes }) {
+ if (HTMLAttributes.isHeader) {
+ return [
+ "th",
+ {
+ ...HTMLAttributes,
+ class: `relative ${HTMLAttributes.class}`,
+ },
+ ["span", { class: "absolute top-0 right-0" }],
+ 0,
+ ];
+ }
+ return ["td", HTMLAttributes, 0];
+ },
+});
diff --git a/packages/editor/src/ui/editor/extensions/table/table-header.ts b/packages/editor/src/ui/editor/extensions/table/table-header.ts
new file mode 100644
index 000000000..f23aa93ef
--- /dev/null
+++ b/packages/editor/src/ui/editor/extensions/table/table-header.ts
@@ -0,0 +1,7 @@
+import { TableHeader as BaseTableHeader } from "@tiptap/extension-table-header";
+
+const TableHeader = BaseTableHeader.extend({
+ content: "paragraph",
+});
+
+export { TableHeader };
diff --git a/packages/editor/src/ui/editor/extensions/table/table.ts b/packages/editor/src/ui/editor/extensions/table/table.ts
new file mode 100644
index 000000000..9b727bb51
--- /dev/null
+++ b/packages/editor/src/ui/editor/extensions/table/table.ts
@@ -0,0 +1,9 @@
+import { Table as BaseTable } from "@tiptap/extension-table";
+
+const Table = BaseTable.configure({
+ resizable: true,
+ cellMinWidth: 100,
+ allowTableNodeSelection: true,
+});
+
+export { Table };
diff --git a/packages/editor/src/ui/editor/menus/bubble-menu/index.tsx b/packages/editor/src/ui/editor/menus/bubble-menu/index.tsx
new file mode 100644
index 000000000..217317ea1
--- /dev/null
+++ b/packages/editor/src/ui/editor/menus/bubble-menu/index.tsx
@@ -0,0 +1,121 @@
+import { BubbleMenu, BubbleMenuProps } from "@tiptap/react";
+import { FC, useState } from "react";
+import { BoldIcon, ItalicIcon, UnderlineIcon, StrikethroughIcon, CodeIcon } from "lucide-react";
+
+import { NodeSelector } from "./node-selector";
+import { LinkSelector } from "./link-selector";
+import { cn } from "../utils";
+
+export interface BubbleMenuItem {
+ name: string;
+ isActive: () => boolean;
+ command: () => void;
+ icon: typeof BoldIcon;
+}
+
+type EditorBubbleMenuProps = Omit;
+
+export const EditorBubbleMenu: FC = (props: any) => {
+ const items: BubbleMenuItem[] = [
+ {
+ name: "bold",
+ isActive: () => props.editor?.isActive("bold"),
+ command: () => props.editor?.chain().focus().toggleBold().run(),
+ icon: BoldIcon,
+ },
+ {
+ name: "italic",
+ isActive: () => props.editor?.isActive("italic"),
+ command: () => props.editor?.chain().focus().toggleItalic().run(),
+ icon: ItalicIcon,
+ },
+ {
+ name: "underline",
+ isActive: () => props.editor?.isActive("underline"),
+ command: () => props.editor?.chain().focus().toggleUnderline().run(),
+ icon: UnderlineIcon,
+ },
+ {
+ name: "strike",
+ isActive: () => props.editor?.isActive("strike"),
+ command: () => props.editor?.chain().focus().toggleStrike().run(),
+ icon: StrikethroughIcon,
+ },
+ {
+ name: "code",
+ isActive: () => props.editor?.isActive("code"),
+ command: () => props.editor?.chain().focus().toggleCode().run(),
+ icon: CodeIcon,
+ },
+ ];
+
+ const bubbleMenuProps: EditorBubbleMenuProps = {
+ ...props,
+ shouldShow: ({ editor }) => {
+ if (!editor.isEditable) {
+ return false;
+ }
+ if (editor.isActive("image")) {
+ return false;
+ }
+ return editor.view.state.selection.content().size > 0;
+ },
+ tippyOptions: {
+ moveTransition: "transform 0.15s ease-out",
+ onHidden: () => {
+ setIsNodeSelectorOpen(false);
+ setIsLinkSelectorOpen(false);
+ },
+ },
+ };
+
+ const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
+ const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
+
+ return (
+
+ {!props.editor.isActive("table") && (
+ {
+ setIsNodeSelectorOpen(!isNodeSelectorOpen);
+ setIsLinkSelectorOpen(false);
+ }}
+ />
+ )}
+ {
+ setIsLinkSelectorOpen(!isLinkSelectorOpen);
+ setIsNodeSelectorOpen(false);
+ }}
+ />
+
+ {items.map((item, index) => (
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/packages/editor/src/ui/editor/menus/bubble-menu/link-selector.tsx b/packages/editor/src/ui/editor/menus/bubble-menu/link-selector.tsx
new file mode 100644
index 000000000..559521db6
--- /dev/null
+++ b/packages/editor/src/ui/editor/menus/bubble-menu/link-selector.tsx
@@ -0,0 +1,92 @@
+import { Editor } from "@tiptap/core";
+import { Check, Trash } from "lucide-react";
+import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react";
+import { cn } from "../utils";
+import isValidHttpUrl from "./utils/link-validator";
+interface LinkSelectorProps {
+ editor: Editor;
+ isOpen: boolean;
+ setIsOpen: Dispatch>;
+}
+
+export const LinkSelector: FC = ({ editor, isOpen, setIsOpen }) => {
+ const inputRef = useRef(null);
+
+ const onLinkSubmit = useCallback(() => {
+ const input = inputRef.current;
+ const url = input?.value;
+ if (url && isValidHttpUrl(url)) {
+ editor.chain().focus().setLink({ href: url }).run();
+ setIsOpen(false);
+ }
+ }, [editor, inputRef, setIsOpen]);
+
+ useEffect(() => {
+ inputRef.current && inputRef.current?.focus();
+ });
+
+ return (
+
+
{
+ setIsOpen(!isOpen);
+ }}
+ >
+ ↗
+
+ Link
+
+
+ {isOpen && (
+
{
+ if (e.key === "Enter") {
+ e.preventDefault();
+ onLinkSubmit();
+ }
+ }}
+ >
+
+ {editor.getAttributes("link").href ? (
+ {
+ editor.chain().focus().unsetLink().run();
+ setIsOpen(false);
+ }}
+ >
+
+
+ ) : (
+ {
+ onLinkSubmit();
+ }}
+ >
+
+
+ )}
+
+ )}
+
+ );
+};
diff --git a/packages/editor/src/ui/editor/menus/bubble-menu/node-selector.tsx b/packages/editor/src/ui/editor/menus/bubble-menu/node-selector.tsx
new file mode 100644
index 000000000..34d40ec06
--- /dev/null
+++ b/packages/editor/src/ui/editor/menus/bubble-menu/node-selector.tsx
@@ -0,0 +1,130 @@
+import { Editor } from "@tiptap/core";
+import {
+ Check,
+ ChevronDown,
+ Heading1,
+ Heading2,
+ Heading3,
+ TextQuote,
+ ListOrdered,
+ TextIcon,
+ Code,
+ CheckSquare,
+} from "lucide-react";
+import { Dispatch, FC, SetStateAction } from "react";
+
+import { BubbleMenuItem } from ".";
+import { cn } from "../utils";
+
+interface NodeSelectorProps {
+ editor: Editor;
+ isOpen: boolean;
+ setIsOpen: Dispatch>;
+}
+
+export const NodeSelector: FC = ({ editor, isOpen, setIsOpen }) => {
+ const items: BubbleMenuItem[] = [
+ {
+ name: "Text",
+ icon: TextIcon,
+ command: () => editor.chain().focus().toggleNode("paragraph", "paragraph").run(),
+ isActive: () =>
+ editor.isActive("paragraph") &&
+ !editor.isActive("bulletList") &&
+ !editor.isActive("orderedList"),
+ },
+ {
+ name: "H1",
+ icon: Heading1,
+ command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
+ isActive: () => editor.isActive("heading", { level: 1 }),
+ },
+ {
+ name: "H2",
+ icon: Heading2,
+ command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
+ isActive: () => editor.isActive("heading", { level: 2 }),
+ },
+ {
+ name: "H3",
+ icon: Heading3,
+ command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
+ isActive: () => editor.isActive("heading", { level: 3 }),
+ },
+ {
+ name: "To-do List",
+ icon: CheckSquare,
+ command: () => editor.chain().focus().toggleTaskList().run(),
+ isActive: () => editor.isActive("taskItem"),
+ },
+ {
+ name: "Bullet List",
+ icon: ListOrdered,
+ command: () => editor.chain().focus().toggleBulletList().run(),
+ isActive: () => editor.isActive("bulletList"),
+ },
+ {
+ name: "Numbered List",
+ icon: ListOrdered,
+ command: () => editor.chain().focus().toggleOrderedList().run(),
+ isActive: () => editor.isActive("orderedList"),
+ },
+ {
+ name: "Quote",
+ icon: TextQuote,
+ command: () =>
+ editor.chain().focus().toggleNode("paragraph", "paragraph").toggleBlockquote().run(),
+ isActive: () => editor.isActive("blockquote"),
+ },
+ {
+ name: "Code",
+ icon: Code,
+ command: () => editor.chain().focus().toggleCodeBlock().run(),
+ isActive: () => editor.isActive("codeBlock"),
+ },
+ ];
+
+ const activeItem = items.filter((item) => item.isActive()).pop() ?? {
+ name: "Multiple",
+ };
+
+ return (
+
+
setIsOpen(!isOpen)}
+ className="flex h-full items-center gap-1 whitespace-nowrap p-2 text-sm font-medium text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5"
+ >
+ {activeItem?.name}
+
+
+
+ {isOpen && (
+
+ {items.map((item, index) => (
+ {
+ item.command();
+ setIsOpen(false);
+ }}
+ className={cn(
+ "flex items-center justify-between rounded-sm px-2 py-1 text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100",
+ { "bg-custom-primary-100/5 text-custom-text-100": activeItem.name === item.name }
+ )}
+ >
+
+ {activeItem.name === item.name && }
+
+ ))}
+
+ )}
+
+ );
+};
diff --git a/packages/editor/src/ui/editor/menus/bubble-menu/utils/link-validator.tsx b/packages/editor/src/ui/editor/menus/bubble-menu/utils/link-validator.tsx
new file mode 100644
index 000000000..9af366c02
--- /dev/null
+++ b/packages/editor/src/ui/editor/menus/bubble-menu/utils/link-validator.tsx
@@ -0,0 +1,11 @@
+export default function isValidHttpUrl(string: string): boolean {
+ let url;
+
+ try {
+ url = new URL(string);
+ } catch (_) {
+ return false;
+ }
+
+ return url.protocol === "http:" || url.protocol === "https:";
+}
diff --git a/packages/editor/src/ui/editor/menus/table-menu/InsertBottomTableIcon.tsx b/packages/editor/src/ui/editor/menus/table-menu/InsertBottomTableIcon.tsx
new file mode 100644
index 000000000..0e42ba648
--- /dev/null
+++ b/packages/editor/src/ui/editor/menus/table-menu/InsertBottomTableIcon.tsx
@@ -0,0 +1,16 @@
+const InsertBottomTableIcon = (props: any) => (
+
+
+
+);
+
+export default InsertBottomTableIcon;
diff --git a/packages/editor/src/ui/editor/menus/table-menu/InsertLeftTableIcon.tsx b/packages/editor/src/ui/editor/menus/table-menu/InsertLeftTableIcon.tsx
new file mode 100644
index 000000000..1fd75fe87
--- /dev/null
+++ b/packages/editor/src/ui/editor/menus/table-menu/InsertLeftTableIcon.tsx
@@ -0,0 +1,15 @@
+const InsertLeftTableIcon = (props: any) => (
+
+
+
+);
+export default InsertLeftTableIcon;
diff --git a/packages/editor/src/ui/editor/menus/table-menu/InsertRightTableIcon.tsx b/packages/editor/src/ui/editor/menus/table-menu/InsertRightTableIcon.tsx
new file mode 100644
index 000000000..1a6570969
--- /dev/null
+++ b/packages/editor/src/ui/editor/menus/table-menu/InsertRightTableIcon.tsx
@@ -0,0 +1,16 @@
+const InsertRightTableIcon = (props: any) => (
+
+
+
+);
+
+export default InsertRightTableIcon;
diff --git a/packages/editor/src/ui/editor/menus/table-menu/InsertTopTableIcon.tsx b/packages/editor/src/ui/editor/menus/table-menu/InsertTopTableIcon.tsx
new file mode 100644
index 000000000..8f04f4f61
--- /dev/null
+++ b/packages/editor/src/ui/editor/menus/table-menu/InsertTopTableIcon.tsx
@@ -0,0 +1,15 @@
+const InsertTopTableIcon = (props: any) => (
+
+
+
+);
+export default InsertTopTableIcon;
diff --git a/packages/editor/src/ui/editor/menus/table-menu/index.tsx b/packages/editor/src/ui/editor/menus/table-menu/index.tsx
new file mode 100644
index 000000000..94f9c0f8d
--- /dev/null
+++ b/packages/editor/src/ui/editor/menus/table-menu/index.tsx
@@ -0,0 +1,143 @@
+import { useState, useEffect } from "react";
+import { Rows, Columns, ToggleRight } from "lucide-react";
+import { cn } from "../utils";
+import { Tooltip } from "components/ui";
+import InsertLeftTableIcon from "./InsertLeftTableIcon";
+import InsertRightTableIcon from "./InsertRightTableIcon";
+import InsertTopTableIcon from "./InsertTopTableIcon";
+import InsertBottomTableIcon from "./InsertBottomTableIcon";
+
+interface TableMenuItem {
+ command: () => void;
+ icon: any;
+ key: string;
+ name: string;
+}
+
+export const findTableAncestor = (node: Node | null): HTMLTableElement | null => {
+ while (node !== null && node.nodeName !== "TABLE") {
+ node = node.parentNode;
+ }
+ return node as HTMLTableElement;
+};
+
+export const TableMenu = ({ editor }: { editor: any }) => {
+ const [tableLocation, setTableLocation] = useState({ bottom: 0, left: 0 });
+ const isOpen = editor?.isActive("table");
+
+ const items: TableMenuItem[] = [
+ {
+ command: () => editor.chain().focus().addColumnBefore().run(),
+ icon: InsertLeftTableIcon,
+ key: "insert-column-left",
+ name: "Insert 1 column left",
+ },
+ {
+ command: () => editor.chain().focus().addColumnAfter().run(),
+ icon: InsertRightTableIcon,
+ key: "insert-column-right",
+ name: "Insert 1 column right",
+ },
+ {
+ command: () => editor.chain().focus().addRowBefore().run(),
+ icon: InsertTopTableIcon,
+ key: "insert-row-above",
+ name: "Insert 1 row above",
+ },
+ {
+ command: () => editor.chain().focus().addRowAfter().run(),
+ icon: InsertBottomTableIcon,
+ key: "insert-row-below",
+ name: "Insert 1 row below",
+ },
+ {
+ command: () => editor.chain().focus().deleteColumn().run(),
+ icon: Columns,
+ key: "delete-column",
+ name: "Delete column",
+ },
+ {
+ command: () => editor.chain().focus().deleteRow().run(),
+ icon: Rows,
+ key: "delete-row",
+ name: "Delete row",
+ },
+ {
+ command: () => editor.chain().focus().toggleHeaderRow().run(),
+ icon: ToggleRight,
+ key: "toggle-header-row",
+ name: "Toggle header row",
+ },
+ ];
+
+ useEffect(() => {
+ if (!window) return;
+
+ const handleWindowClick = () => {
+ const selection: any = window?.getSelection();
+
+ if (selection.rangeCount !== 0) {
+ const range = selection.getRangeAt(0);
+ const tableNode = findTableAncestor(range.startContainer);
+
+ let parent = tableNode?.parentElement;
+
+ if (tableNode) {
+ const tableRect = tableNode.getBoundingClientRect();
+ const tableCenter = tableRect.left + tableRect.width / 2;
+ const menuWidth = 45;
+ const menuLeft = tableCenter - menuWidth / 2;
+ const tableBottom = tableRect.bottom;
+
+ setTableLocation({ bottom: tableBottom, left: menuLeft });
+
+ while (parent) {
+ if (!parent.classList.contains("disable-scroll"))
+ parent.classList.add("disable-scroll");
+ parent = parent.parentElement;
+ }
+ } else {
+ const scrollDisabledContainers = document.querySelectorAll(".disable-scroll");
+
+ scrollDisabledContainers.forEach((container) => {
+ container.classList.remove("disable-scroll");
+ });
+ }
+ }
+ };
+
+ window.addEventListener("click", handleWindowClick);
+
+ return () => {
+ window.removeEventListener("click", handleWindowClick);
+ };
+ }, [tableLocation, editor]);
+
+ return (
+
+ {items.map((item, index) => (
+
+
+
+
+
+ ))}
+
+ );
+};
diff --git a/packages/editor/src/ui/editor/menus/table-menu/tooltip.tsx b/packages/editor/src/ui/editor/menus/table-menu/tooltip.tsx
new file mode 100644
index 000000000..f29d8a491
--- /dev/null
+++ b/packages/editor/src/ui/editor/menus/table-menu/tooltip.tsx
@@ -0,0 +1,77 @@
+import * as React from 'react';
+
+// next-themes
+import { useTheme } from "next-themes";
+// tooltip2
+import { Tooltip2 } from "@blueprintjs/popover2";
+
+type Props = {
+ tooltipHeading?: string;
+ tooltipContent: string | React.ReactNode;
+ position?:
+ | "top"
+ | "right"
+ | "bottom"
+ | "left"
+ | "auto"
+ | "auto-end"
+ | "auto-start"
+ | "bottom-left"
+ | "bottom-right"
+ | "left-bottom"
+ | "left-top"
+ | "right-bottom"
+ | "right-top"
+ | "top-left"
+ | "top-right";
+ children: JSX.Element;
+ disabled?: boolean;
+ className?: string;
+ openDelay?: number;
+ closeDelay?: number;
+};
+
+export const Tooltip: React.FC = ({
+ tooltipHeading,
+ tooltipContent,
+ position = "top",
+ children,
+ disabled = false,
+ className = "",
+ openDelay = 200,
+ closeDelay,
+}) => {
+ const { theme } = useTheme();
+
+ return (
+
+ {tooltipHeading && (
+
+ {tooltipHeading}
+
+ )}
+ {tooltipContent}
+
+ }
+ position={position}
+ renderTarget={({ isOpen: isTooltipOpen, ref: eleReference, ...tooltipProps }) =>
+ React.cloneElement(children, { ref: eleReference, ...tooltipProps, ...children.props })
+ }
+ />
+ );
+};
diff --git a/packages/editor/src/ui/editor/plugins/delete-image.tsx b/packages/editor/src/ui/editor/plugins/delete-image.tsx
new file mode 100644
index 000000000..fdf515ccc
--- /dev/null
+++ b/packages/editor/src/ui/editor/plugins/delete-image.tsx
@@ -0,0 +1,68 @@
+import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
+import { Node as ProseMirrorNode } from "@tiptap/pm/model";
+import fileService from "services/file.service";
+
+const deleteKey = new PluginKey("delete-image");
+const IMAGE_NODE_TYPE = "image";
+
+interface ImageNode extends ProseMirrorNode {
+ attrs: {
+ src: string;
+ id: string;
+ };
+}
+
+const TrackImageDeletionPlugin = (): Plugin =>
+ new Plugin({
+ key: deleteKey,
+ appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
+ const newImageSources = new Set();
+ newState.doc.descendants((node) => {
+ if (node.type.name === IMAGE_NODE_TYPE) {
+ newImageSources.add(node.attrs.src);
+ }
+ });
+
+ transactions.forEach((transaction) => {
+ if (!transaction.docChanged) return;
+
+ const removedImages: ImageNode[] = [];
+
+ oldState.doc.descendants((oldNode, oldPos) => {
+ if (oldNode.type.name !== IMAGE_NODE_TYPE) return;
+ if (oldPos < 0 || oldPos > newState.doc.content.size) return;
+ if (!newState.doc.resolve(oldPos).parent) return;
+
+ const newNode = newState.doc.nodeAt(oldPos);
+
+ // Check if the node has been deleted or replaced
+ if (!newNode || newNode.type.name !== IMAGE_NODE_TYPE) {
+ if (!newImageSources.has(oldNode.attrs.src)) {
+ removedImages.push(oldNode as ImageNode);
+ }
+ }
+ });
+
+ removedImages.forEach(async (node) => {
+ const src = node.attrs.src;
+ await onNodeDeleted(src);
+ });
+ });
+
+ return null;
+ },
+ });
+
+export default TrackImageDeletionPlugin;
+
+async function onNodeDeleted(src: string): Promise {
+ try {
+ const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
+ const resStatus = await fileService.deleteImage(assetUrlWithWorkspaceId);
+ if (resStatus === 204) {
+ console.log("Image deleted successfully");
+ }
+ } catch (error) {
+ console.error("Error deleting image: ", error);
+ }
+}
diff --git a/packages/editor/src/ui/editor/plugins/upload-image.tsx b/packages/editor/src/ui/editor/plugins/upload-image.tsx
new file mode 100644
index 000000000..bc0acdc54
--- /dev/null
+++ b/packages/editor/src/ui/editor/plugins/upload-image.tsx
@@ -0,0 +1,127 @@
+import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
+import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
+import fileService from "services/file.service";
+
+const uploadKey = new PluginKey("upload-image");
+
+const UploadImagesPlugin = () =>
+ new Plugin({
+ key: uploadKey,
+ state: {
+ init() {
+ return DecorationSet.empty;
+ },
+ apply(tr, set) {
+ set = set.map(tr.mapping, tr.doc);
+ // See if the transaction adds or removes any placeholders
+ const action = tr.getMeta(uploadKey);
+ if (action && action.add) {
+ const { id, pos, src } = action.add;
+
+ const placeholder = document.createElement("div");
+ placeholder.setAttribute("class", "img-placeholder");
+ const image = document.createElement("img");
+ image.setAttribute("class", "opacity-10 rounded-lg border border-custom-border-300");
+ image.src = src;
+ placeholder.appendChild(image);
+ const deco = Decoration.widget(pos + 1, placeholder, {
+ id,
+ });
+ set = set.add(tr.doc, [deco]);
+ } else if (action && action.remove) {
+ set = set.remove(set.find(undefined, undefined, (spec) => spec.id == action.remove.id));
+ }
+ return set;
+ },
+ },
+ props: {
+ decorations(state) {
+ return this.getState(state);
+ },
+ },
+ });
+
+export default UploadImagesPlugin;
+
+function findPlaceholder(state: EditorState, id: {}) {
+ const decos = uploadKey.getState(state);
+ const found = decos.find(
+ undefined,
+ undefined,
+ (spec: { id: number | undefined }) => spec.id == id
+ );
+ return found.length ? found[0].from : null;
+}
+
+export async function startImageUpload(
+ file: File,
+ view: EditorView,
+ pos: number,
+ workspaceSlug: string,
+ setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
+) {
+ if (!file.type.includes("image/")) {
+ return;
+ }
+
+ const id = {};
+
+ const tr = view.state.tr;
+ if (!tr.selection.empty) tr.deleteSelection();
+
+ const reader = new FileReader();
+ reader.readAsDataURL(file);
+ reader.onload = () => {
+ tr.setMeta(uploadKey, {
+ add: {
+ id,
+ pos,
+ src: reader.result,
+ },
+ });
+ view.dispatch(tr);
+ };
+
+ if (!workspaceSlug) {
+ return;
+ }
+ setIsSubmitting?.("submitting");
+ const src = await UploadImageHandler(file, workspaceSlug);
+ const { schema } = view.state;
+ pos = findPlaceholder(view.state, id);
+
+ if (pos == null) return;
+ const imageSrc = typeof src === "object" ? reader.result : src;
+
+ const node = schema.nodes.image.create({ src: imageSrc });
+ const transaction = view.state.tr
+ .replaceWith(pos, pos, node)
+ .setMeta(uploadKey, { remove: { id } });
+ view.dispatch(transaction);
+}
+
+const UploadImageHandler = (file: File, workspaceSlug: string): Promise => {
+ if (!workspaceSlug) {
+ return Promise.reject("Workspace slug is missing");
+ }
+ try {
+ const formData = new FormData();
+ formData.append("asset", file);
+ formData.append("attributes", JSON.stringify({}));
+
+ return new Promise(async (resolve, reject) => {
+ const imageUrl = await fileService
+ .uploadFile(workspaceSlug, formData)
+ .then((response) => response.asset);
+
+ const image = new Image();
+ image.src = imageUrl;
+ image.onload = () => {
+ resolve(imageUrl);
+ };
+ });
+ } catch (error) {
+ console.log(error);
+ return Promise.reject(error);
+ }
+};
diff --git a/packages/editor/src/ui/editor/props.tsx b/packages/editor/src/ui/editor/props.tsx
new file mode 100644
index 000000000..9c478024b
--- /dev/null
+++ b/packages/editor/src/ui/editor/props.tsx
@@ -0,0 +1,69 @@
+import { EditorProps } from "@tiptap/pm/view";
+import { findTableAncestor } from "@/ui/editor/menus/table-menu";
+import { startImageUpload } from "@/ui/editor/plugins/upload-image";
+
+export function TiptapEditorProps(
+ workspaceSlug: string,
+ setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
+): EditorProps {
+ return {
+ attributes: {
+ class: `prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none`,
+ },
+ handleDOMEvents: {
+ keydown: (_view, event) => {
+ // prevent default event listeners from firing when slash command is active
+ if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
+ const slashCommand = document.querySelector("#slash-command");
+ if (slashCommand) {
+ return true;
+ }
+ }
+ },
+ },
+ handlePaste: (view, event) => {
+ if (typeof window !== "undefined") {
+ const selection: any = window?.getSelection();
+ if (selection.rangeCount !== 0) {
+ const range = selection.getRangeAt(0);
+ if (findTableAncestor(range.startContainer)) {
+ return;
+ }
+ }
+ }
+ if (event.clipboardData && event.clipboardData.files && event.clipboardData.files[0]) {
+ event.preventDefault();
+ const file = event.clipboardData.files[0];
+ const pos = view.state.selection.from;
+ startImageUpload(file, view, pos, workspaceSlug, setIsSubmitting);
+ return true;
+ }
+ return false;
+ },
+ handleDrop: (view, event, _slice, moved) => {
+ if (typeof window !== "undefined") {
+ const selection: any = window?.getSelection();
+ if (selection.rangeCount !== 0) {
+ const range = selection.getRangeAt(0);
+ if (findTableAncestor(range.startContainer)) {
+ return;
+ }
+ }
+ }
+ if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) {
+ event.preventDefault();
+ const file = event.dataTransfer.files[0];
+ const coordinates = view.posAtCoords({
+ left: event.clientX,
+ top: event.clientY,
+ });
+ // here we deduct 1 from the pos or else the image will create an extra node
+ if (coordinates) {
+ startImageUpload(file, view, coordinates.pos - 1, workspaceSlug, setIsSubmitting);
+ }
+ return true;
+ }
+ return false;
+ },
+ };
+}
diff --git a/packages/editor/src/utils.ts b/packages/editor/src/utils.ts
new file mode 100644
index 000000000..a5ef19350
--- /dev/null
+++ b/packages/editor/src/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/packages/editor/tailwind.config.js b/packages/editor/tailwind.config.js
new file mode 100644
index 000000000..12079a19b
--- /dev/null
+++ b/packages/editor/tailwind.config.js
@@ -0,0 +1,6 @@
+const sharedConfig = require("tailwind-config/tailwind.config.js");
+
+module.exports = {
+ // prefix ui lib classes to avoid conflicting with the app
+ ...sharedConfig,
+};
diff --git a/packages/editor/tsconfig.json b/packages/editor/tsconfig.json
new file mode 100644
index 000000000..8a6aeae90
--- /dev/null
+++ b/packages/editor/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "tsconfig/react.json",
+ "include": ["."],
+ "exclude": ["dist", "build", "node_modules"],
+ "compilerOptions": {
+ "jsx": "react-jsx",
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ }
+}
diff --git a/packages/editor/tsup.config.ts b/packages/editor/tsup.config.ts
new file mode 100644
index 000000000..1173495dd
--- /dev/null
+++ b/packages/editor/tsup.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig, Options } from "tsup";
+
+export default defineConfig((options: Options) => ({
+ entry: ["src/index.ts"],
+ banner: {
+ js: "'use client'",
+ },
+ format: ["cjs", "esm"],
+ dts: true,
+ clean: true,
+ external: ["react"],
+ injectStyle: true,
+ ...options,
+}));
diff --git a/packages/tailwind-config/package.json b/packages/tailwind-config/package.json
new file mode 100644
index 000000000..6d7dbbc7a
--- /dev/null
+++ b/packages/tailwind-config/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "tailwind-config",
+ "version": "0.0.0",
+ "private": true,
+ "main": "index.js",
+ "devDependencies": {
+ "@tailwindcss/typography": "^0.5.9",
+ "autoprefixer": "^10.4.14",
+ "postcss": "^8.4.21",
+ "prettier": "^2.8.8",
+ "prettier-plugin-tailwindcss": "^0.3.0",
+ "tailwindcss": "^3.2.7",
+ "tailwindcss-animate": "^1.0.6"
+ }
+}
diff --git a/packages/tailwind-config/tailwind.config.js b/packages/tailwind-config/tailwind.config.js
new file mode 100644
index 000000000..0b7b5861a
--- /dev/null
+++ b/packages/tailwind-config/tailwind.config.js
@@ -0,0 +1,206 @@
+const convertToRGB = (variableName) => `rgba(var(${variableName}))`;
+
+module.exports = {
+ darkMode: "class",
+ content: ["./pages/**/*.tsx", "./components/**/*.tsx", "./layouts/**/*.tsx", "./ui/**/*.tsx"],
+ theme: {
+ extend: {
+ boxShadow: {
+ "custom-shadow-2xs": "var(--color-shadow-2xs)",
+ "custom-shadow-xs": "var(--color-shadow-xs)",
+ "custom-shadow-sm": "var(--color-shadow-sm)",
+ "custom-shadow-rg": "var(--color-shadow-rg)",
+ "custom-shadow-md": "var(--color-shadow-md)",
+ "custom-shadow-lg": "var(--color-shadow-lg)",
+ "custom-shadow-xl": "var(--color-shadow-xl)",
+ "custom-shadow-2xl": "var(--color-shadow-2xl)",
+ "custom-shadow-3xl": "var(--color-shadow-3xl)",
+ "custom-sidebar-shadow-2xs": "var(--color-sidebar-shadow-2xs)",
+ "custom-sidebar-shadow-xs": "var(--color-sidebar-shadow-xs)",
+ "custom-sidebar-shadow-sm": "var(--color-sidebar-shadow-sm)",
+ "custom-sidebar-shadow-rg": "var(--color-sidebar-shadow-rg)",
+ "custom-sidebar-shadow-md": "var(--color-sidebar-shadow-md)",
+ "custom-sidebar-shadow-lg": "var(--color-sidebar-shadow-lg)",
+ "custom-sidebar-shadow-xl": "var(--color-sidebar-shadow-xl)",
+ "custom-sidebar-shadow-2xl": "var(--color-sidebar-shadow-2xl)",
+ "custom-sidebar-shadow-3xl": "var(--color-sidebar-shadow-3xl)",
+ },
+ colors: {
+ custom: {
+ primary: {
+ 0: "rgb(255, 255, 255)",
+ 10: convertToRGB("--color-primary-10"),
+ 20: convertToRGB("--color-primary-20"),
+ 30: convertToRGB("--color-primary-30"),
+ 40: convertToRGB("--color-primary-40"),
+ 50: convertToRGB("--color-primary-50"),
+ 60: convertToRGB("--color-primary-60"),
+ 70: convertToRGB("--color-primary-70"),
+ 80: convertToRGB("--color-primary-80"),
+ 90: convertToRGB("--color-primary-90"),
+ 100: convertToRGB("--color-primary-100"),
+ 200: convertToRGB("--color-primary-200"),
+ 300: convertToRGB("--color-primary-300"),
+ 400: convertToRGB("--color-primary-400"),
+ 500: convertToRGB("--color-primary-500"),
+ 600: convertToRGB("--color-primary-600"),
+ 700: convertToRGB("--color-primary-700"),
+ 800: convertToRGB("--color-primary-800"),
+ 900: convertToRGB("--color-primary-900"),
+ 1000: "rgb(0, 0, 0)",
+ DEFAULT: convertToRGB("--color-primary-100"),
+ },
+ background: {
+ 0: "rgb(255, 255, 255)",
+ 10: convertToRGB("--color-background-10"),
+ 20: convertToRGB("--color-background-20"),
+ 30: convertToRGB("--color-background-30"),
+ 40: convertToRGB("--color-background-40"),
+ 50: convertToRGB("--color-background-50"),
+ 60: convertToRGB("--color-background-60"),
+ 70: convertToRGB("--color-background-70"),
+ 80: convertToRGB("--color-background-80"),
+ 90: convertToRGB("--color-background-90"),
+ 100: convertToRGB("--color-background-100"),
+ 200: convertToRGB("--color-background-200"),
+ 300: convertToRGB("--color-background-300"),
+ 400: convertToRGB("--color-background-400"),
+ 500: convertToRGB("--color-background-500"),
+ 600: convertToRGB("--color-background-600"),
+ 700: convertToRGB("--color-background-700"),
+ 800: convertToRGB("--color-background-800"),
+ 900: convertToRGB("--color-background-900"),
+ 1000: "rgb(0, 0, 0)",
+ DEFAULT: convertToRGB("--color-background-100"),
+ },
+ text: {
+ 0: "rgb(255, 255, 255)",
+ 10: convertToRGB("--color-text-10"),
+ 20: convertToRGB("--color-text-20"),
+ 30: convertToRGB("--color-text-30"),
+ 40: convertToRGB("--color-text-40"),
+ 50: convertToRGB("--color-text-50"),
+ 60: convertToRGB("--color-text-60"),
+ 70: convertToRGB("--color-text-70"),
+ 80: convertToRGB("--color-text-80"),
+ 90: convertToRGB("--color-text-90"),
+ 100: convertToRGB("--color-text-100"),
+ 200: convertToRGB("--color-text-200"),
+ 300: convertToRGB("--color-text-300"),
+ 400: convertToRGB("--color-text-400"),
+ 500: convertToRGB("--color-text-500"),
+ 600: convertToRGB("--color-text-600"),
+ 700: convertToRGB("--color-text-700"),
+ 800: convertToRGB("--color-text-800"),
+ 900: convertToRGB("--color-text-900"),
+ 1000: "rgb(0, 0, 0)",
+ DEFAULT: convertToRGB("--color-text-100"),
+ },
+ border: {
+ 0: "rgb(255, 255, 255)",
+ 100: convertToRGB("--color-border-100"),
+ 200: convertToRGB("--color-border-200"),
+ 300: convertToRGB("--color-border-300"),
+ 400: convertToRGB("--color-border-400"),
+ 1000: "rgb(0, 0, 0)",
+ DEFAULT: convertToRGB("--color-border-200"),
+ },
+ sidebar: {
+ background: {
+ 0: "rgb(255, 255, 255)",
+ 10: convertToRGB("--color-sidebar-background-10"),
+ 20: convertToRGB("--color-sidebar-background-20"),
+ 30: convertToRGB("--color-sidebar-background-30"),
+ 40: convertToRGB("--color-sidebar-background-40"),
+ 50: convertToRGB("--color-sidebar-background-50"),
+ 60: convertToRGB("--color-sidebar-background-60"),
+ 70: convertToRGB("--color-sidebar-background-70"),
+ 80: convertToRGB("--color-sidebar-background-80"),
+ 90: convertToRGB("--color-sidebar-background-90"),
+ 100: convertToRGB("--color-sidebar-background-100"),
+ 200: convertToRGB("--color-sidebar-background-200"),
+ 300: convertToRGB("--color-sidebar-background-300"),
+ 400: convertToRGB("--color-sidebar-background-400"),
+ 500: convertToRGB("--color-sidebar-background-500"),
+ 600: convertToRGB("--color-sidebar-background-600"),
+ 700: convertToRGB("--color-sidebar-background-700"),
+ 800: convertToRGB("--color-sidebar-background-800"),
+ 900: convertToRGB("--color-sidebar-background-900"),
+ 1000: "rgb(0, 0, 0)",
+ DEFAULT: convertToRGB("--color-sidebar-background-100"),
+ },
+ text: {
+ 0: "rgb(255, 255, 255)",
+ 10: convertToRGB("--color-sidebar-text-10"),
+ 20: convertToRGB("--color-sidebar-text-20"),
+ 30: convertToRGB("--color-sidebar-text-30"),
+ 40: convertToRGB("--color-sidebar-text-40"),
+ 50: convertToRGB("--color-sidebar-text-50"),
+ 60: convertToRGB("--color-sidebar-text-60"),
+ 70: convertToRGB("--color-sidebar-text-70"),
+ 80: convertToRGB("--color-sidebar-text-80"),
+ 90: convertToRGB("--color-sidebar-text-90"),
+ 100: convertToRGB("--color-sidebar-text-100"),
+ 200: convertToRGB("--color-sidebar-text-200"),
+ 300: convertToRGB("--color-sidebar-text-300"),
+ 400: convertToRGB("--color-sidebar-text-400"),
+ 500: convertToRGB("--color-sidebar-text-500"),
+ 600: convertToRGB("--color-sidebar-text-600"),
+ 700: convertToRGB("--color-sidebar-text-700"),
+ 800: convertToRGB("--color-sidebar-text-800"),
+ 900: convertToRGB("--color-sidebar-text-900"),
+ 1000: "rgb(0, 0, 0)",
+ DEFAULT: convertToRGB("--color-sidebar-text-100"),
+ },
+ border: {
+ 0: "rgb(255, 255, 255)",
+ 100: convertToRGB("--color-sidebar-border-100"),
+ 200: convertToRGB("--color-sidebar-border-200"),
+ 300: convertToRGB("--color-sidebar-border-300"),
+ 400: convertToRGB("--color-sidebar-border-400"),
+ 1000: "rgb(0, 0, 0)",
+ DEFAULT: convertToRGB("--color-sidebar-border-200"),
+ },
+ },
+ backdrop: "#131313",
+ },
+ },
+ keyframes: {
+ leftToaster: {
+ "0%": { left: "-20rem" },
+ "100%": { left: "0" },
+ },
+ rightToaster: {
+ "0%": { right: "-20rem" },
+ "100%": { right: "0" },
+ },
+ },
+ typography: ({ theme }) => ({
+ brand: {
+ css: {
+ "--tw-prose-body": convertToRGB("--color-text-100"),
+ "--tw-prose-p": convertToRGB("--color-text-100"),
+ "--tw-prose-headings": convertToRGB("--color-text-100"),
+ "--tw-prose-lead": convertToRGB("--color-text-100"),
+ "--tw-prose-links": convertToRGB("--color-primary-100"),
+ "--tw-prose-bold": convertToRGB("--color-text-100"),
+ "--tw-prose-counters": convertToRGB("--color-text-100"),
+ "--tw-prose-bullets": convertToRGB("--color-text-100"),
+ "--tw-prose-hr": convertToRGB("--color-text-100"),
+ "--tw-prose-quotes": convertToRGB("--color-text-100"),
+ "--tw-prose-quote-borders": convertToRGB("--color-border"),
+ "--tw-prose-code": convertToRGB("--color-text-100"),
+ "--tw-prose-pre-code": convertToRGB("--color-text-100"),
+ "--tw-prose-pre-bg": convertToRGB("--color-background-100"),
+ "--tw-prose-th-borders": convertToRGB("--color-border"),
+ "--tw-prose-td-borders": convertToRGB("--color-border"),
+ },
+ },
+ }),
+ },
+ fontFamily: {
+ custom: ["Inter", "sans-serif"],
+ },
+ },
+ plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
+};