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 @@ + + This is the core engine that supports Rich Text Editing at Plane +

Plane's Editor

+
+ +

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

+ +

+ Hacker News + + License + + Novel.sh's GitHub repo +

+ +

+ 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) => ( + + ))} +
+ ) : 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 ( +
+ + {isOpen && ( +
{ + if (e.key === "Enter") { + e.preventDefault(); + onLinkSubmit(); + } + }} + > + + {editor.getAttributes("link").href ? ( + + ) : ( + + )} +
+ )} +
+ ); +}; 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 ( +
+ + + {isOpen && ( +
+ {items.map((item, index) => ( + + ))} +
+ )} +
+ ); +}; 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")], +};