diff --git a/packages/editor/package.json b/packages/editor/package.json
index 990d306aa..a6a61cffb 100644
--- a/packages/editor/package.json
+++ b/packages/editor/package.json
@@ -25,6 +25,7 @@
},
"dependencies": {
"@blueprintjs/popover2": "^2.0.10",
+ "@radix-ui/react-slot": "^1.0.2",
"@tiptap/core": "^2.1.7",
"@tiptap/extension-code-block-lowlight": "^2.0.4",
"@tiptap/extension-color": "^2.1.11",
@@ -48,6 +49,7 @@
"@types/node": "18.15.3",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
+ "class-variance-authority": "^0.7.0",
"clsx": "^1.2.1",
"eslint": "8.36.0",
"eslint-config-next": "13.2.4",
diff --git a/packages/editor/src/ui/editor/index.tsx b/packages/editor/src/ui/editor/index.tsx
index 0952869a7..286768f51 100644
--- a/packages/editor/src/ui/editor/index.tsx
+++ b/packages/editor/src/ui/editor/index.tsx
@@ -11,6 +11,7 @@ import { TiptapEditorProps } from '@/ui/editor/props';
import { UploadImage } from '@/types/upload-image';
import { DeleteImage } from '@/types/delete-image';
import { cn } from '@/lib/utils';
+import { FixedMenu } from './menus/fixed-menu';
interface ITiptapEditor {
value: string;
@@ -109,11 +110,17 @@ const TiptapEditor = ({
}}
className={`tiptap-editor-container cursor-text ${editorClassNames}`}
>
- {editor && }
-
-
-
- {editor?.isActive("image") &&
}
+
+
+
+
+ {editor?.isActive("image") &&
}
+
+ {editor && editable !== false &&
+ (
+
+
)
+ }
);
diff --git a/packages/editor/src/ui/editor/menus/fixed-menu/index.tsx b/packages/editor/src/ui/editor/menus/fixed-menu/index.tsx
new file mode 100644
index 000000000..31a7ad90d
--- /dev/null
+++ b/packages/editor/src/ui/editor/menus/fixed-menu/index.tsx
@@ -0,0 +1,78 @@
+import { BubbleMenu, BubbleMenuProps } from "@tiptap/react";
+import { FC, useState } from "react";
+import { BoldIcon, ItalicIcon, UnderlineIcon, StrikethroughIcon, CodeIcon } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+export interface BubbleMenuItem {
+ name: string;
+ isActive: () => boolean;
+ command: () => void;
+ icon: typeof BoldIcon;
+}
+
+type EditorBubbleMenuProps = Omit;
+
+export const FixedMenu: 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,
+ },
+ ];
+
+
+ return (
+
+
+ {items.map((item, index) => (
+
+ ))}
+
+
+ );
+};
diff --git a/packages/editor/src/ui/light-editor/extensions/image/image-resize.tsx b/packages/editor/src/ui/light-editor/extensions/image/image-resize.tsx
new file mode 100644
index 000000000..448b8811c
--- /dev/null
+++ b/packages/editor/src/ui/light-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/light-editor/extensions/image/updated-image.tsx b/packages/editor/src/ui/light-editor/extensions/image/updated-image.tsx
new file mode 100644
index 000000000..2ba977f57
--- /dev/null
+++ b/packages/editor/src/ui/light-editor/extensions/image/updated-image.tsx
@@ -0,0 +1,23 @@
+import Image from "@tiptap/extension-image";
+import TrackImageDeletionPlugin from "@/ui/editor/plugins/delete-image";
+import UploadImagesPlugin from "@/ui/editor/plugins/upload-image";
+import { DeleteImage } from "@/types/delete-image";
+
+const UpdatedImage = (deleteImage: DeleteImage) => Image.extend({
+ addProseMirrorPlugins() {
+ return [UploadImagesPlugin(), TrackImageDeletionPlugin(deleteImage)];
+ },
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+ width: {
+ default: "35%",
+ },
+ height: {
+ default: null,
+ },
+ };
+ },
+});
+
+export default UpdatedImage;
diff --git a/packages/editor/src/ui/light-editor/extensions/index.tsx b/packages/editor/src/ui/light-editor/extensions/index.tsx
new file mode 100644
index 000000000..5fd7f9ad1
--- /dev/null
+++ b/packages/editor/src/ui/light-editor/extensions/index.tsx
@@ -0,0 +1,155 @@
+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 { InputRule } from "@tiptap/core";
+import Gapcursor from "@tiptap/extension-gapcursor";
+import { Table } from "@/ui/editor/extensions/table/table";
+import { TableHeader } from "@/ui/editor/extensions/table/table-header";
+import { TableRow } from "@tiptap/extension-table-row";
+import { CustomTableCell } from "@/ui/editor/extensions/table/table-cell";
+
+import UpdatedImage from "@/ui/editor/extensions/image/updated-image";
+import SlashCommand from "@/ui/editor/extensions/slash-command";
+
+import { DeleteImage } from "@/types/delete-image";
+import { UploadImage } from "@/types/upload-image";
+
+import isValidHttpUrl from "@/ui/editor/menus/bubble-menu/utils"
+
+import ts from "highlight.js/lib/languages/typescript";
+import { lowlight } from "lowlight/lib/core";
+import "highlight.js/styles/github-dark.css";
+
+lowlight.registerLanguage("ts", ts);
+
+export const TiptapExtensions = (
+ workspaceSlug: string,
+ uploadFile: UploadImage,
+ deleteFile: DeleteImage,
+ 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(deleteFile).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,
+ }),
+ SlashCommand(workspaceSlug, uploadFile, 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/light-editor/extensions/slash-command.tsx b/packages/editor/src/ui/light-editor/extensions/slash-command.tsx
new file mode 100644
index 000000000..4fba7af5f
--- /dev/null
+++ b/packages/editor/src/ui/light-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 "@/ui/editor/plugins/upload-image";
+import { cn } from "@/lib/utils";
+import { UploadImage } from "@/types/upload-image";
+
+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,
+ uploadFile: UploadImage,
+ 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) => {
+ // @ts-ignore
+ 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) => {
+ // @ts-ignore
+ editor.chain().focus().deleteRange(range).toggleOrderedList().run();
+ },
+ },
+ {
+ title: "Quote",
+ description: "Capture a quote.",
+ searchTerms: ["blockquote"],
+ icon: ,
+ command: ({ editor, range }: CommandProps) =>
+ // @ts-ignore
+ 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, uploadFile, 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,
+ uploadFile: UploadImage,
+ setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
+) =>
+ Command.configure({
+ suggestion: {
+ items: getSuggestionItems(workspaceSlug, uploadFile, setIsSubmitting),
+ render: renderItems,
+ },
+ });
+
+export default SlashCommand;
diff --git a/packages/editor/src/ui/light-editor/extensions/table/table-cell.ts b/packages/editor/src/ui/light-editor/extensions/table/table-cell.ts
new file mode 100644
index 000000000..643cb8c64
--- /dev/null
+++ b/packages/editor/src/ui/light-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/light-editor/extensions/table/table-header.ts b/packages/editor/src/ui/light-editor/extensions/table/table-header.ts
new file mode 100644
index 000000000..f23aa93ef
--- /dev/null
+++ b/packages/editor/src/ui/light-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/light-editor/extensions/table/table.ts b/packages/editor/src/ui/light-editor/extensions/table/table.ts
new file mode 100644
index 000000000..9b727bb51
--- /dev/null
+++ b/packages/editor/src/ui/light-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/light-editor/index.tsx b/packages/editor/src/ui/light-editor/index.tsx
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/editor/src/ui/light-editor/menus/bubble-menu/index.tsx b/packages/editor/src/ui/light-editor/menus/bubble-menu/index.tsx
new file mode 100644
index 000000000..9592cf617
--- /dev/null
+++ b/packages/editor/src/ui/light-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 "@/lib/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/light-editor/menus/bubble-menu/link-selector.tsx b/packages/editor/src/ui/light-editor/menus/bubble-menu/link-selector.tsx
new file mode 100644
index 000000000..4f8284506
--- /dev/null
+++ b/packages/editor/src/ui/light-editor/menus/bubble-menu/link-selector.tsx
@@ -0,0 +1,93 @@
+import { cn } from "@/lib/utils";
+import { Editor } from "@tiptap/core";
+import { Check, Trash } from "lucide-react";
+import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react";
+import isValidHttpUrl from "@/ui/editor/menus/bubble-menu/utils";
+
+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/light-editor/menus/bubble-menu/node-selector.tsx b/packages/editor/src/ui/light-editor/menus/bubble-menu/node-selector.tsx
new file mode 100644
index 000000000..999184506
--- /dev/null
+++ b/packages/editor/src/ui/light-editor/menus/bubble-menu/node-selector.tsx
@@ -0,0 +1,130 @@
+import { cn } from "@/lib/utils";
+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 ".";
+
+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/light-editor/menus/bubble-menu/utils/index.tsx b/packages/editor/src/ui/light-editor/menus/bubble-menu/utils/index.tsx
new file mode 100644
index 000000000..b5add3f54
--- /dev/null
+++ b/packages/editor/src/ui/light-editor/menus/bubble-menu/utils/index.tsx
@@ -0,0 +1,11 @@
+export default function isValidHttpUrl(string: string): boolean {
+ let url: URL;
+
+ try {
+ url = new URL(string);
+ } catch (_) {
+ return false;
+ }
+
+ return url.protocol === "http:" || url.protocol === "https:";
+}
diff --git a/packages/editor/src/ui/light-editor/menus/table-menu/InsertBottomTableIcon.tsx b/packages/editor/src/ui/light-editor/menus/table-menu/InsertBottomTableIcon.tsx
new file mode 100644
index 000000000..0e42ba648
--- /dev/null
+++ b/packages/editor/src/ui/light-editor/menus/table-menu/InsertBottomTableIcon.tsx
@@ -0,0 +1,16 @@
+const InsertBottomTableIcon = (props: any) => (
+
+);
+
+export default InsertBottomTableIcon;
diff --git a/packages/editor/src/ui/light-editor/menus/table-menu/InsertLeftTableIcon.tsx b/packages/editor/src/ui/light-editor/menus/table-menu/InsertLeftTableIcon.tsx
new file mode 100644
index 000000000..1fd75fe87
--- /dev/null
+++ b/packages/editor/src/ui/light-editor/menus/table-menu/InsertLeftTableIcon.tsx
@@ -0,0 +1,15 @@
+const InsertLeftTableIcon = (props: any) => (
+
+);
+export default InsertLeftTableIcon;
diff --git a/packages/editor/src/ui/light-editor/menus/table-menu/InsertRightTableIcon.tsx b/packages/editor/src/ui/light-editor/menus/table-menu/InsertRightTableIcon.tsx
new file mode 100644
index 000000000..1a6570969
--- /dev/null
+++ b/packages/editor/src/ui/light-editor/menus/table-menu/InsertRightTableIcon.tsx
@@ -0,0 +1,16 @@
+const InsertRightTableIcon = (props: any) => (
+
+);
+
+export default InsertRightTableIcon;
diff --git a/packages/editor/src/ui/light-editor/menus/table-menu/InsertTopTableIcon.tsx b/packages/editor/src/ui/light-editor/menus/table-menu/InsertTopTableIcon.tsx
new file mode 100644
index 000000000..8f04f4f61
--- /dev/null
+++ b/packages/editor/src/ui/light-editor/menus/table-menu/InsertTopTableIcon.tsx
@@ -0,0 +1,15 @@
+const InsertTopTableIcon = (props: any) => (
+
+);
+export default InsertTopTableIcon;
diff --git a/packages/editor/src/ui/light-editor/menus/table-menu/index.tsx b/packages/editor/src/ui/light-editor/menus/table-menu/index.tsx
new file mode 100644
index 000000000..4b342e6e6
--- /dev/null
+++ b/packages/editor/src/ui/light-editor/menus/table-menu/index.tsx
@@ -0,0 +1,143 @@
+import { useState, useEffect } from "react";
+import { Rows, Columns, ToggleRight } from "lucide-react";
+import InsertLeftTableIcon from "./InsertLeftTableIcon";
+import InsertRightTableIcon from "./InsertRightTableIcon";
+import InsertTopTableIcon from "./InsertTopTableIcon";
+import InsertBottomTableIcon from "./InsertBottomTableIcon";
+import { cn } from "@/lib/utils";
+import { Tooltip } from "./tooltip";
+
+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/light-editor/menus/table-menu/tooltip.tsx b/packages/editor/src/ui/light-editor/menus/table-menu/tooltip.tsx
new file mode 100644
index 000000000..f29d8a491
--- /dev/null
+++ b/packages/editor/src/ui/light-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/light-editor/plugins/delete-image.tsx b/packages/editor/src/ui/light-editor/plugins/delete-image.tsx
new file mode 100644
index 000000000..9204481a8
--- /dev/null
+++ b/packages/editor/src/ui/light-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 { DeleteImage } from "@/types/delete-image";
+
+const deleteKey = new PluginKey("delete-image");
+const IMAGE_NODE_TYPE = "image";
+
+interface ImageNode extends ProseMirrorNode {
+ attrs: {
+ src: string;
+ id: string;
+ };
+}
+
+const TrackImageDeletionPlugin = (deleteImage: DeleteImage): 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, deleteImage);
+ });
+ });
+
+ return null;
+ },
+ });
+
+export default TrackImageDeletionPlugin;
+
+async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise {
+ try {
+ const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
+ const resStatus = await 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/light-editor/plugins/upload-image.tsx b/packages/editor/src/ui/light-editor/plugins/upload-image.tsx
new file mode 100644
index 000000000..4c3bbf9a8
--- /dev/null
+++ b/packages/editor/src/ui/light-editor/plugins/upload-image.tsx
@@ -0,0 +1,138 @@
+import { UploadImage } from "@/types/upload-image";
+import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
+import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
+
+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,
+ uploadFile: UploadImage,
+ 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, uploadFile);
+ 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,
+ uploadFile: UploadImage
+): 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) => {
+ try {
+ const imageUrl = await uploadFile(workspaceSlug, formData)
+ .then((response: { asset: string }) => response.asset);
+
+ const image = new Image();
+ image.src = imageUrl;
+ image.onload = () => {
+ resolve(imageUrl);
+ };
+ } catch (error) {
+ if (error instanceof Error) {
+ console.log(error.message);
+ }
+ reject(error);
+ }
+ });
+ } catch (error) {
+ if (error instanceof Error) {
+ console.log(error.message);
+ }
+ return Promise.reject(error);
+ }
+};
diff --git a/packages/editor/src/ui/light-editor/props.tsx b/packages/editor/src/ui/light-editor/props.tsx
new file mode 100644
index 000000000..ff5b2f11b
--- /dev/null
+++ b/packages/editor/src/ui/light-editor/props.tsx
@@ -0,0 +1,71 @@
+import { EditorProps } from "@tiptap/pm/view";
+import { findTableAncestor } from "@/ui/editor/menus/table-menu";
+import { startImageUpload } from "@/ui/editor/plugins/upload-image";
+import { UploadImage } from "@/types/upload-image";
+
+export function TiptapEditorProps(
+ workspaceSlug: string,
+ uploadFile: UploadImage,
+ 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, uploadFile, 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, uploadFile, setIsSubmitting);
+ }
+ return true;
+ }
+ return false;
+ },
+ };
+}
diff --git a/packages/editor/tsup.config.ts b/packages/editor/tsup.config.ts
index 236e425ed..5e89e04af 100644
--- a/packages/editor/tsup.config.ts
+++ b/packages/editor/tsup.config.ts
@@ -4,7 +4,7 @@ export default defineConfig((options: Options) => ({
entry: ["src/index.ts"],
format: ["cjs", "esm"],
dts: true,
- clean: true,
+ clean: false,
external: ["react"],
injectStyle: true,
...options,
diff --git a/web/components/issues/comment/add-comment.tsx b/web/components/issues/comment/add-comment.tsx
index ef1d4cec7..78b38e1d3 100644
--- a/web/components/issues/comment/add-comment.tsx
+++ b/web/components/issues/comment/add-comment.tsx
@@ -66,38 +66,6 @@ export const AddComment: React.FC = ({