mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
removed old tiptap dependency
This commit is contained in:
parent
fa01dfc652
commit
36fd719f7f
@ -1,121 +0,0 @@
|
|||||||
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<BubbleMenuProps, "children">;
|
|
||||||
|
|
||||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (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 (
|
|
||||||
<BubbleMenu
|
|
||||||
{...bubbleMenuProps}
|
|
||||||
className="flex w-fit divide-x divide-custom-border-300 rounded border border-custom-border-300 bg-custom-background-100 shadow-xl"
|
|
||||||
>
|
|
||||||
{!props.editor.isActive("table") && (
|
|
||||||
<NodeSelector
|
|
||||||
editor={props.editor!}
|
|
||||||
isOpen={isNodeSelectorOpen}
|
|
||||||
setIsOpen={() => {
|
|
||||||
setIsNodeSelectorOpen(!isNodeSelectorOpen);
|
|
||||||
setIsLinkSelectorOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<LinkSelector
|
|
||||||
editor={props.editor!!}
|
|
||||||
isOpen={isLinkSelectorOpen}
|
|
||||||
setIsOpen={() => {
|
|
||||||
setIsLinkSelectorOpen(!isLinkSelectorOpen);
|
|
||||||
setIsNodeSelectorOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex">
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
type="button"
|
|
||||||
onClick={item.command}
|
|
||||||
className={cn(
|
|
||||||
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
|
|
||||||
{
|
|
||||||
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<item.icon
|
|
||||||
className={cn("h-4 w-4", {
|
|
||||||
"text-custom-text-100": item.isActive(),
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</BubbleMenu>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,92 +0,0 @@
|
|||||||
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<SetStateAction<boolean>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen }) => {
|
|
||||||
const inputRef = useRef<HTMLInputElement>(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 (
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"flex h-full items-center space-x-2 px-3 py-1.5 text-sm font-medium text-custom-text-300 hover:bg-custom-background-100 active:bg-custom-background-100",
|
|
||||||
{ "bg-custom-background-100": isOpen }
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
setIsOpen(!isOpen);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p className="text-base">↗</p>
|
|
||||||
<p
|
|
||||||
className={cn("underline underline-offset-4", {
|
|
||||||
"text-custom-text-100": editor.isActive("link"),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
Link
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
{isOpen && (
|
|
||||||
<div
|
|
||||||
className="fixed top-full z-[99999] mt-1 flex w-60 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 dow-xl animate-in fade-in slide-in-from-top-1"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
onLinkSubmit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="url"
|
|
||||||
placeholder="Paste a link"
|
|
||||||
className="flex-1 bg-custom-background-100 border-r border-custom-border-300 p-1 text-sm outline-none placeholder:text-custom-text-400"
|
|
||||||
defaultValue={editor.getAttributes("link").href || ""}
|
|
||||||
/>
|
|
||||||
{editor.getAttributes("link").href ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800"
|
|
||||||
onClick={() => {
|
|
||||||
editor.chain().focus().unsetLink().run();
|
|
||||||
setIsOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="flex items-center rounded-sm p-1 text-custom-text-300 transition-all hover:bg-custom-background-90"
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onLinkSubmit();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,130 +0,0 @@
|
|||||||
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<SetStateAction<boolean>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NodeSelector: FC<NodeSelectorProps> = ({ 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 (
|
|
||||||
<div className="relative h-full">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
|
||||||
className="flex h-full items-center gap-1 whitespace-nowrap p-2 text-sm font-medium text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5"
|
|
||||||
>
|
|
||||||
<span>{activeItem?.name}</span>
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isOpen && (
|
|
||||||
<section className="fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 p-1 shadow-xl animate-in fade-in slide-in-from-top-1">
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
item.command();
|
|
||||||
setIsOpen(false);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-between rounded-sm px-2 py-1 text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100",
|
|
||||||
{ "bg-custom-primary-100/5 text-custom-text-100": activeItem.name === item.name }
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="rounded-sm border border-custom-border-300 p-1">
|
|
||||||
<item.icon className="h-3 w-3" />
|
|
||||||
</div>
|
|
||||||
<span>{item.name}</span>
|
|
||||||
</div>
|
|
||||||
{activeItem.name === item.name && <Check className="h-4 w-4" />}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,11 +0,0 @@
|
|||||||
export default function isValidHttpUrl(string: string): boolean {
|
|
||||||
let url;
|
|
||||||
|
|
||||||
try {
|
|
||||||
url = new URL(string);
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return url.protocol === "http:" || url.protocol === "https:";
|
|
||||||
}
|
|
@ -1,44 +0,0 @@
|
|||||||
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 (
|
|
||||||
<>
|
|
||||||
<Moveable
|
|
||||||
target={document.querySelector(".ProseMirror-selectednode") as any}
|
|
||||||
container={null}
|
|
||||||
origin={false}
|
|
||||||
edge={false}
|
|
||||||
throttleDrag={0}
|
|
||||||
keepRatio={true}
|
|
||||||
resizable={true}
|
|
||||||
throttleResize={0}
|
|
||||||
onResize={({ target, width, height, delta }: any) => {
|
|
||||||
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;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,149 +0,0 @@
|
|||||||
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 SlashCommand from "../slash-command";
|
|
||||||
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 UpdatedImage from "./updated-image";
|
|
||||||
import isValidHttpUrl from "../bubble-menu/utils/link-validator";
|
|
||||||
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,
|
|
||||||
}),
|
|
||||||
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,
|
|
||||||
];
|
|
@ -1,32 +0,0 @@
|
|||||||
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];
|
|
||||||
},
|
|
||||||
});
|
|
@ -1,7 +0,0 @@
|
|||||||
import { TableHeader as BaseTableHeader } from "@tiptap/extension-table-header";
|
|
||||||
|
|
||||||
const TableHeader = BaseTableHeader.extend({
|
|
||||||
content: "paragraph",
|
|
||||||
});
|
|
||||||
|
|
||||||
export { TableHeader };
|
|
@ -1,9 +0,0 @@
|
|||||||
import { Table as BaseTable } from "@tiptap/extension-table";
|
|
||||||
|
|
||||||
const Table = BaseTable.configure({
|
|
||||||
resizable: true,
|
|
||||||
cellMinWidth: 100,
|
|
||||||
allowTableNodeSelection: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
export { Table };
|
|
@ -1,22 +0,0 @@
|
|||||||
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;
|
|
@ -1,110 +0,0 @@
|
|||||||
import { useImperativeHandle, useRef, forwardRef, useEffect } from "react";
|
|
||||||
import { useEditor, EditorContent, Editor } from "@tiptap/react";
|
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
|
||||||
// components
|
|
||||||
import { EditorBubbleMenu } from "./bubble-menu";
|
|
||||||
import { TiptapExtensions } from "./extensions";
|
|
||||||
import { TiptapEditorProps } from "./props";
|
|
||||||
import { ImageResizer } from "./extensions/image-resize";
|
|
||||||
import { TableMenu } from "./table-menu";
|
|
||||||
|
|
||||||
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<Editor | null> = 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 (
|
|
||||||
<div
|
|
||||||
id="tiptap-container"
|
|
||||||
onClick={() => {
|
|
||||||
editor?.chain().focus().run();
|
|
||||||
}}
|
|
||||||
className={`tiptap-editor-container cursor-text ${editorClassNames}`}
|
|
||||||
>
|
|
||||||
{editor && <EditorBubbleMenu editor={editor} />}
|
|
||||||
<div className={`${editorContentCustomClassNames}`}>
|
|
||||||
<EditorContent editor={editor} />
|
|
||||||
<TableMenu editor={editor} />
|
|
||||||
{editor?.isActive("image") && <ImageResizer editor={editor} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const TipTapEditor = forwardRef<ITipTapRichTextEditor, ITipTapRichTextEditor>((props, ref) => (
|
|
||||||
<Tiptap {...props} forwardedRef={ref} />
|
|
||||||
));
|
|
||||||
|
|
||||||
TipTapEditor.displayName = "TipTapEditor";
|
|
||||||
|
|
||||||
export { TipTapEditor };
|
|
@ -1,68 +0,0 @@
|
|||||||
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<void> {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,127 +0,0 @@
|
|||||||
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<string> => {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
@ -1,69 +0,0 @@
|
|||||||
import { EditorProps } from "@tiptap/pm/view";
|
|
||||||
import { startImageUpload } from "./plugins/upload-image";
|
|
||||||
import { findTableAncestor } from "./table-menu";
|
|
||||||
|
|
||||||
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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,365 +0,0 @@
|
|||||||
import React, { 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: <Text size={18} />,
|
|
||||||
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: <Heading1 size={18} />,
|
|
||||||
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: <Heading2 size={18} />,
|
|
||||||
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: <Heading3 size={18} />,
|
|
||||||
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: <CheckSquare size={18} />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
editor.chain().focus().deleteRange(range).toggleTaskList().run();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Bullet List",
|
|
||||||
description: "Create a simple bullet list.",
|
|
||||||
searchTerms: ["unordered", "point"],
|
|
||||||
icon: <List size={18} />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
editor.chain().focus().deleteRange(range).toggleBulletList().run();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Divider",
|
|
||||||
description: "Visually divide blocks",
|
|
||||||
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
|
|
||||||
icon: <MinusSquare size={18} />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Table",
|
|
||||||
description: "Create a Table",
|
|
||||||
searchTerms: ["table", "cell", "db", "data", "tabular"],
|
|
||||||
icon: <Table size={18} />,
|
|
||||||
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: <ListOrdered size={18} />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Quote",
|
|
||||||
description: "Capture a quote.",
|
|
||||||
searchTerms: ["blockquote"],
|
|
||||||
icon: <TextQuote size={18} />,
|
|
||||||
command: ({ editor, range }: CommandProps) =>
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.deleteRange(range)
|
|
||||||
.toggleNode("paragraph", "paragraph")
|
|
||||||
.toggleBlockquote()
|
|
||||||
.run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Code",
|
|
||||||
description: "Capture a code snippet.",
|
|
||||||
searchTerms: ["codeblock"],
|
|
||||||
icon: <Code size={18} />,
|
|
||||||
command: ({ editor, range }: CommandProps) =>
|
|
||||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Image",
|
|
||||||
description: "Upload an image from your computer.",
|
|
||||||
searchTerms: ["photo", "picture", "media"],
|
|
||||||
icon: <ImageIcon size={18} />,
|
|
||||||
command: ({ editor, range }: 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<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const container = commandListContainer?.current;
|
|
||||||
|
|
||||||
const item = container?.children[selectedIndex] as HTMLElement;
|
|
||||||
|
|
||||||
if (item && container) updateScrollView(container, item);
|
|
||||||
}, [selectedIndex]);
|
|
||||||
|
|
||||||
return items.length > 0 ? (
|
|
||||||
<div
|
|
||||||
id="slash-command"
|
|
||||||
ref={commandListContainer}
|
|
||||||
className="z-50 fixed h-auto max-h-[330px] w-72 overflow-y-auto rounded-md border border-custom-border-300 bg-custom-background-100 px-1 py-2 shadow-md transition-all"
|
|
||||||
>
|
|
||||||
{items.map((item: CommandItemProps, index: number) => (
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
`flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100`,
|
|
||||||
{ "bg-custom-primary-100/5 text-custom-text-100": index === selectedIndex }
|
|
||||||
)}
|
|
||||||
key={index}
|
|
||||||
onClick={() => selectItem(index)}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{item.title}</p>
|
|
||||||
<p className="text-xs text-custom-text-300">{item.description}</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : 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;
|
|
@ -1,16 +0,0 @@
|
|||||||
const InsertBottomTableIcon = (props: any) => (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
viewBox="0 -960 960 960"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M212.309-152.31q-30.308 0-51.308-21t-21-51.307V-360q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307v135.383q0 30.307-21 51.307-21 21-51.308 21H212.309Zm0-375.383q-30.308 0-51.308-21t-21-51.307v-135.383q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307V-600q0 30.307-21 51.307-21 21-51.308 21H212.309Zm535.382-219.998H212.309q-4.616 0-8.463 3.846-3.846 3.846-3.846 8.462V-600q0 4.616 3.846 8.462 3.847 3.847 8.463 3.847h535.382q4.616 0 8.463-3.847Q760-595.384 760-600v-135.383q0-4.616-3.846-8.462-3.847-3.846-8.463-3.846ZM200-587.691v-160 160Z"
|
|
||||||
fill="rgb(var(--color-text-300))"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default InsertBottomTableIcon;
|
|
@ -1,15 +0,0 @@
|
|||||||
const InsertLeftTableIcon = (props: any) => (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
viewBox="0 -960 960 960"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M224.617-140.001q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21H360q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H224.617Zm375.383 0q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21h135.383q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H600Zm147.691-607.69q0-4.616-3.846-8.463-3.846-3.846-8.462-3.846H600q-4.616 0-8.462 3.846-3.847 3.847-3.847 8.463v535.382q0 4.616 3.847 8.463Q595.384-200 600-200h135.383q4.616 0 8.462-3.846 3.846-3.847 3.846-8.463v-535.382ZM587.691-200h160-160Z"
|
|
||||||
fill="rgb(var(--color-text-300))"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
export default InsertLeftTableIcon;
|
|
@ -1,16 +0,0 @@
|
|||||||
const InsertRightTableIcon = (props: any) => (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
viewBox="0 -960 960 960"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M600-140.001q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21h135.383q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H600Zm-375.383 0q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21H360q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H224.617Zm-12.308-607.69v535.382q0 4.616 3.846 8.463 3.846 3.846 8.462 3.846H360q4.616 0 8.462-3.846 3.847-3.847 3.847-8.463v-535.382q0-4.616-3.847-8.463Q364.616-760 360-760H224.617q-4.616 0-8.462 3.846-3.846 3.847-3.846 8.463Zm160 547.691h-160 160Z"
|
|
||||||
fill="rgb(var(--color-text-300))"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default InsertRightTableIcon;
|
|
@ -1,15 +0,0 @@
|
|||||||
const InsertTopTableIcon = (props: any) => (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
viewBox="0 -960 960 960"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M212.309-527.693q-30.308 0-51.308-21t-21-51.307v-135.383q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307V-600q0 30.307-21 51.307-21 21-51.308 21H212.309Zm0 375.383q-30.308 0-51.308-21t-21-51.307V-360q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307v135.383q0 30.307-21 51.307-21 21-51.308 21H212.309Zm0-59.999h535.382q4.616 0 8.463-3.846 3.846-3.846 3.846-8.462V-360q0-4.616-3.846-8.462-3.847-3.847-8.463-3.847H212.309q-4.616 0-8.463 3.847Q200-364.616 200-360v135.383q0 4.616 3.846 8.462 3.847 3.846 8.463 3.846Zm-12.309-160v160-160Z"
|
|
||||||
fill="rgb(var(--color-text-300))"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
export default InsertTopTableIcon;
|
|
@ -1,143 +0,0 @@
|
|||||||
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 (
|
|
||||||
<section
|
|
||||||
className={`fixed left-1/2 transform -translate-x-1/2 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-sm p-1 ${
|
|
||||||
isOpen ? "block" : "hidden"
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
bottom: `calc(100vh - ${tableLocation.bottom + 45}px)`,
|
|
||||||
left: `${tableLocation.left}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<Tooltip key={index} tooltipContent={item.name}>
|
|
||||||
<button
|
|
||||||
onClick={item.command}
|
|
||||||
className="p-1.5 text-custom-text-200 hover:bg-text-custom-text-100 hover:bg-custom-background-80 active:bg-custom-background-80 rounded"
|
|
||||||
title={item.name}
|
|
||||||
>
|
|
||||||
<item.icon
|
|
||||||
className={cn("h-4 w-4 text-lg", {
|
|
||||||
"text-red-600": item.key.includes("delete"),
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,6 +0,0 @@
|
|||||||
import { clsx, type ClassValue } from "clsx";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs));
|
|
||||||
}
|
|
@ -17,26 +17,6 @@
|
|||||||
"@heroicons/react": "^2.0.12",
|
"@heroicons/react": "^2.0.12",
|
||||||
"@mui/icons-material": "^5.14.1",
|
"@mui/icons-material": "^5.14.1",
|
||||||
"@mui/material": "^5.14.1",
|
"@mui/material": "^5.14.1",
|
||||||
"@tiptap/extension-code-block-lowlight": "^2.0.4",
|
|
||||||
"@tiptap/extension-color": "^2.0.4",
|
|
||||||
"@tiptap/extension-gapcursor": "^2.1.7",
|
|
||||||
"@tiptap/extension-highlight": "^2.0.4",
|
|
||||||
"@tiptap/extension-horizontal-rule": "^2.0.4",
|
|
||||||
"@tiptap/extension-image": "^2.0.4",
|
|
||||||
"@tiptap/extension-link": "^2.0.4",
|
|
||||||
"@tiptap/extension-placeholder": "^2.0.4",
|
|
||||||
"@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.0.4",
|
|
||||||
"@tiptap/extension-task-list": "^2.0.4",
|
|
||||||
"@tiptap/extension-text-style": "^2.0.4",
|
|
||||||
"@tiptap/extension-underline": "^2.0.4",
|
|
||||||
"@tiptap/pm": "^2.0.4",
|
|
||||||
"@tiptap/react": "^2.0.4",
|
|
||||||
"@tiptap/starter-kit": "^2.0.4",
|
|
||||||
"@tiptap/suggestion": "^2.0.4",
|
|
||||||
"axios": "^1.3.4",
|
"axios": "^1.3.4",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"js-cookie": "^3.0.1",
|
"js-cookie": "^3.0.1",
|
||||||
@ -51,12 +31,9 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hook-form": "^7.38.0",
|
"react-hook-form": "^7.38.0",
|
||||||
"react-moveable": "^0.54.1",
|
|
||||||
"swr": "^2.2.2",
|
"swr": "^2.2.2",
|
||||||
"tailwind-merge": "^1.14.0",
|
"tailwind-merge": "^1.14.0",
|
||||||
"tiptap-markdown": "^0.8.2",
|
|
||||||
"typescript": "4.9.5",
|
"typescript": "4.9.5",
|
||||||
"use-debounce": "^9.0.4",
|
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
"@plane/editor": "*"
|
"@plane/editor": "*"
|
||||||
},
|
},
|
||||||
|
@ -1,121 +0,0 @@
|
|||||||
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<BubbleMenuProps, "children">;
|
|
||||||
|
|
||||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (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 (
|
|
||||||
<BubbleMenu
|
|
||||||
{...bubbleMenuProps}
|
|
||||||
className="flex w-fit divide-x divide-custom-border-300 rounded border border-custom-border-300 bg-custom-background-100 shadow-xl"
|
|
||||||
>
|
|
||||||
{!props.editor.isActive("table") && (
|
|
||||||
<NodeSelector
|
|
||||||
editor={props.editor!}
|
|
||||||
isOpen={isNodeSelectorOpen}
|
|
||||||
setIsOpen={() => {
|
|
||||||
setIsNodeSelectorOpen(!isNodeSelectorOpen);
|
|
||||||
setIsLinkSelectorOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<LinkSelector
|
|
||||||
editor={props.editor!!}
|
|
||||||
isOpen={isLinkSelectorOpen}
|
|
||||||
setIsOpen={() => {
|
|
||||||
setIsLinkSelectorOpen(!isLinkSelectorOpen);
|
|
||||||
setIsNodeSelectorOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex">
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
type="button"
|
|
||||||
onClick={item.command}
|
|
||||||
className={cn(
|
|
||||||
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
|
|
||||||
{
|
|
||||||
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<item.icon
|
|
||||||
className={cn("h-4 w-4", {
|
|
||||||
"text-custom-text-100": item.isActive(),
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</BubbleMenu>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,92 +0,0 @@
|
|||||||
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<SetStateAction<boolean>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen }) => {
|
|
||||||
const inputRef = useRef<HTMLInputElement>(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 (
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"flex h-full items-center space-x-2 px-3 py-1.5 text-sm font-medium text-custom-text-300 hover:bg-custom-background-100 active:bg-custom-background-100",
|
|
||||||
{ "bg-custom-background-100": isOpen }
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
setIsOpen(!isOpen);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p className="text-base">↗</p>
|
|
||||||
<p
|
|
||||||
className={cn("underline underline-offset-4", {
|
|
||||||
"text-custom-text-100": editor.isActive("link"),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
Link
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
{isOpen && (
|
|
||||||
<div
|
|
||||||
className="fixed top-full z-[99999] mt-1 flex w-60 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 dow-xl animate-in fade-in slide-in-from-top-1"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
onLinkSubmit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="url"
|
|
||||||
placeholder="Paste a link"
|
|
||||||
className="flex-1 bg-custom-background-100 border-r border-custom-border-300 p-1 text-sm outline-none placeholder:text-custom-text-400"
|
|
||||||
defaultValue={editor.getAttributes("link").href || ""}
|
|
||||||
/>
|
|
||||||
{editor.getAttributes("link").href ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800"
|
|
||||||
onClick={() => {
|
|
||||||
editor.chain().focus().unsetLink().run();
|
|
||||||
setIsOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="flex items-center rounded-sm p-1 text-custom-text-300 transition-all hover:bg-custom-background-90"
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onLinkSubmit();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,130 +0,0 @@
|
|||||||
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<SetStateAction<boolean>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NodeSelector: FC<NodeSelectorProps> = ({ 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 (
|
|
||||||
<div className="relative h-full">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
|
||||||
className="flex h-full items-center gap-1 whitespace-nowrap p-2 text-sm font-medium text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5"
|
|
||||||
>
|
|
||||||
<span>{activeItem?.name}</span>
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isOpen && (
|
|
||||||
<section className="fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 p-1 shadow-xl animate-in fade-in slide-in-from-top-1">
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
item.command();
|
|
||||||
setIsOpen(false);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-between rounded-sm px-2 py-1 text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100",
|
|
||||||
{ "bg-custom-primary-100/5 text-custom-text-100": activeItem.name === item.name }
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="rounded-sm border border-custom-border-300 p-1">
|
|
||||||
<item.icon className="h-3 w-3" />
|
|
||||||
</div>
|
|
||||||
<span>{item.name}</span>
|
|
||||||
</div>
|
|
||||||
{activeItem.name === item.name && <Check className="h-4 w-4" />}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,11 +0,0 @@
|
|||||||
export default function isValidHttpUrl(string: string): boolean {
|
|
||||||
let url;
|
|
||||||
|
|
||||||
try {
|
|
||||||
url = new URL(string);
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return url.protocol === "http:" || url.protocol === "https:";
|
|
||||||
}
|
|
@ -1,44 +0,0 @@
|
|||||||
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 (
|
|
||||||
<>
|
|
||||||
<Moveable
|
|
||||||
target={document.querySelector(".ProseMirror-selectednode") as any}
|
|
||||||
container={null}
|
|
||||||
origin={false}
|
|
||||||
edge={false}
|
|
||||||
throttleDrag={0}
|
|
||||||
keepRatio={true}
|
|
||||||
resizable={true}
|
|
||||||
throttleResize={0}
|
|
||||||
onResize={({ target, width, height, delta }: any) => {
|
|
||||||
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;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,149 +0,0 @@
|
|||||||
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 SlashCommand from "../slash-command";
|
|
||||||
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 UpdatedImage from "./updated-image";
|
|
||||||
import isValidHttpUrl from "../bubble-menu/utils/link-validator";
|
|
||||||
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,
|
|
||||||
}),
|
|
||||||
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,
|
|
||||||
];
|
|
@ -1,32 +0,0 @@
|
|||||||
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];
|
|
||||||
},
|
|
||||||
});
|
|
@ -1,7 +0,0 @@
|
|||||||
import { TableHeader as BaseTableHeader } from "@tiptap/extension-table-header";
|
|
||||||
|
|
||||||
const TableHeader = BaseTableHeader.extend({
|
|
||||||
content: "paragraph",
|
|
||||||
});
|
|
||||||
|
|
||||||
export { TableHeader };
|
|
@ -1,9 +0,0 @@
|
|||||||
import { Table as BaseTable } from "@tiptap/extension-table";
|
|
||||||
|
|
||||||
const Table = BaseTable.configure({
|
|
||||||
resizable: true,
|
|
||||||
cellMinWidth: 100,
|
|
||||||
allowTableNodeSelection: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
export { Table };
|
|
@ -1,22 +0,0 @@
|
|||||||
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;
|
|
@ -1,110 +0,0 @@
|
|||||||
import { useImperativeHandle, useRef, forwardRef } from "react";
|
|
||||||
import { useEditor, EditorContent, Editor } from "@tiptap/react";
|
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
|
||||||
// components
|
|
||||||
import { EditorBubbleMenu } from "./bubble-menu";
|
|
||||||
import { TiptapExtensions } from "./extensions";
|
|
||||||
import { TiptapEditorProps } from "./props";
|
|
||||||
import { ImageResizer } from "./extensions/image-resize";
|
|
||||||
import { TableMenu } from "./table-menu";
|
|
||||||
|
|
||||||
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: (typeof value === "string" && value.trim() !== "") ? value : "<p></p>",
|
|
||||||
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<Editor | null> = 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 (
|
|
||||||
<div
|
|
||||||
id="tiptap-container"
|
|
||||||
onClick={() => {
|
|
||||||
editor?.chain().focus().run();
|
|
||||||
}}
|
|
||||||
className={`tiptap-editor-container cursor-text ${editorClassNames}`}
|
|
||||||
>
|
|
||||||
{editor && <EditorBubbleMenu editor={editor} />}
|
|
||||||
<div className={`${editorContentCustomClassNames}`}>
|
|
||||||
<EditorContent editor={editor} />
|
|
||||||
<TableMenu editor={editor} />
|
|
||||||
{editor?.isActive("image") && <ImageResizer editor={editor} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const TipTapEditor = forwardRef<ITipTapRichTextEditor, ITipTapRichTextEditor>((props, ref) => (
|
|
||||||
<Tiptap {...props} forwardedRef={ref} />
|
|
||||||
));
|
|
||||||
|
|
||||||
TipTapEditor.displayName = "TipTapEditor";
|
|
||||||
|
|
||||||
export { TipTapEditor };
|
|
@ -1,68 +0,0 @@
|
|||||||
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<void> {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,127 +0,0 @@
|
|||||||
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<string> => {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
@ -1,69 +0,0 @@
|
|||||||
import { EditorProps } from "@tiptap/pm/view";
|
|
||||||
import { startImageUpload } from "./plugins/upload-image";
|
|
||||||
import { findTableAncestor } from "./table-menu";
|
|
||||||
|
|
||||||
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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,365 +0,0 @@
|
|||||||
import React, { 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: <Text size={18} />,
|
|
||||||
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: <Heading1 size={18} />,
|
|
||||||
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: <Heading2 size={18} />,
|
|
||||||
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: <Heading3 size={18} />,
|
|
||||||
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: <CheckSquare size={18} />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
editor.chain().focus().deleteRange(range).toggleTaskList().run();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Bullet List",
|
|
||||||
description: "Create a simple bullet list.",
|
|
||||||
searchTerms: ["unordered", "point"],
|
|
||||||
icon: <List size={18} />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
editor.chain().focus().deleteRange(range).toggleBulletList().run();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Divider",
|
|
||||||
description: "Visually divide blocks",
|
|
||||||
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
|
|
||||||
icon: <MinusSquare size={18} />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Table",
|
|
||||||
description: "Create a Table",
|
|
||||||
searchTerms: ["table", "cell", "db", "data", "tabular"],
|
|
||||||
icon: <Table size={18} />,
|
|
||||||
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: <ListOrdered size={18} />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Quote",
|
|
||||||
description: "Capture a quote.",
|
|
||||||
searchTerms: ["blockquote"],
|
|
||||||
icon: <TextQuote size={18} />,
|
|
||||||
command: ({ editor, range }: CommandProps) =>
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.deleteRange(range)
|
|
||||||
.toggleNode("paragraph", "paragraph")
|
|
||||||
.toggleBlockquote()
|
|
||||||
.run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Code",
|
|
||||||
description: "Capture a code snippet.",
|
|
||||||
searchTerms: ["codeblock"],
|
|
||||||
icon: <Code size={18} />,
|
|
||||||
command: ({ editor, range }: CommandProps) =>
|
|
||||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Image",
|
|
||||||
description: "Upload an image from your computer.",
|
|
||||||
searchTerms: ["photo", "picture", "media"],
|
|
||||||
icon: <ImageIcon size={18} />,
|
|
||||||
command: ({ editor, range }: 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<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const container = commandListContainer?.current;
|
|
||||||
|
|
||||||
const item = container?.children[selectedIndex] as HTMLElement;
|
|
||||||
|
|
||||||
if (item && container) updateScrollView(container, item);
|
|
||||||
}, [selectedIndex]);
|
|
||||||
|
|
||||||
return items.length > 0 ? (
|
|
||||||
<div
|
|
||||||
id="slash-command"
|
|
||||||
ref={commandListContainer}
|
|
||||||
className="z-50 fixed h-auto max-h-[330px] w-72 overflow-y-auto rounded-md border border-custom-border-300 bg-custom-background-100 px-1 py-2 shadow-md transition-all"
|
|
||||||
>
|
|
||||||
{items.map((item: CommandItemProps, index: number) => (
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
`flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100`,
|
|
||||||
{ "bg-custom-primary-100/5 text-custom-text-100": index === selectedIndex }
|
|
||||||
)}
|
|
||||||
key={index}
|
|
||||||
onClick={() => selectItem(index)}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{item.title}</p>
|
|
||||||
<p className="text-xs text-custom-text-300">{item.description}</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : 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;
|
|
@ -1,16 +0,0 @@
|
|||||||
const InsertBottomTableIcon = (props: any) => (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
viewBox="0 -960 960 960"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M212.309-152.31q-30.308 0-51.308-21t-21-51.307V-360q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307v135.383q0 30.307-21 51.307-21 21-51.308 21H212.309Zm0-375.383q-30.308 0-51.308-21t-21-51.307v-135.383q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307V-600q0 30.307-21 51.307-21 21-51.308 21H212.309Zm535.382-219.998H212.309q-4.616 0-8.463 3.846-3.846 3.846-3.846 8.462V-600q0 4.616 3.846 8.462 3.847 3.847 8.463 3.847h535.382q4.616 0 8.463-3.847Q760-595.384 760-600v-135.383q0-4.616-3.846-8.462-3.847-3.846-8.463-3.846ZM200-587.691v-160 160Z"
|
|
||||||
fill="rgb(var(--color-text-300))"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default InsertBottomTableIcon;
|
|
@ -1,15 +0,0 @@
|
|||||||
const InsertLeftTableIcon = (props: any) => (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
viewBox="0 -960 960 960"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M224.617-140.001q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21H360q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H224.617Zm375.383 0q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21h135.383q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H600Zm147.691-607.69q0-4.616-3.846-8.463-3.846-3.846-8.462-3.846H600q-4.616 0-8.462 3.846-3.847 3.847-3.847 8.463v535.382q0 4.616 3.847 8.463Q595.384-200 600-200h135.383q4.616 0 8.462-3.846 3.846-3.847 3.846-8.463v-535.382ZM587.691-200h160-160Z"
|
|
||||||
fill="rgb(var(--color-text-300))"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
export default InsertLeftTableIcon;
|
|
@ -1,16 +0,0 @@
|
|||||||
const InsertRightTableIcon = (props: any) => (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
viewBox="0 -960 960 960"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M600-140.001q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21h135.383q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H600Zm-375.383 0q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21H360q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H224.617Zm-12.308-607.69v535.382q0 4.616 3.846 8.463 3.846 3.846 8.462 3.846H360q4.616 0 8.462-3.846 3.847-3.847 3.847-8.463v-535.382q0-4.616-3.847-8.463Q364.616-760 360-760H224.617q-4.616 0-8.462 3.846-3.846 3.847-3.846 8.463Zm160 547.691h-160 160Z"
|
|
||||||
fill="rgb(var(--color-text-300))"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default InsertRightTableIcon;
|
|
@ -1,15 +0,0 @@
|
|||||||
const InsertTopTableIcon = (props: any) => (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
viewBox="0 -960 960 960"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M212.309-527.693q-30.308 0-51.308-21t-21-51.307v-135.383q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307V-600q0 30.307-21 51.307-21 21-51.308 21H212.309Zm0 375.383q-30.308 0-51.308-21t-21-51.307V-360q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307v135.383q0 30.307-21 51.307-21 21-51.308 21H212.309Zm0-59.999h535.382q4.616 0 8.463-3.846 3.846-3.846 3.846-8.462V-360q0-4.616-3.846-8.462-3.847-3.847-8.463-3.847H212.309q-4.616 0-8.463 3.847Q200-364.616 200-360v135.383q0 4.616 3.846 8.462 3.847 3.846 8.463 3.846Zm-12.309-160v160-160Z"
|
|
||||||
fill="rgb(var(--color-text-300))"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
export default InsertTopTableIcon;
|
|
@ -1,143 +0,0 @@
|
|||||||
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 (
|
|
||||||
<section
|
|
||||||
className={`fixed left-1/2 transform -translate-x-1/2 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-sm p-1 ${
|
|
||||||
isOpen ? "block" : "hidden"
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
bottom: `calc(100vh - ${tableLocation.bottom + 45}px)`,
|
|
||||||
left: `${tableLocation.left}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<Tooltip key={index} tooltipContent={item.name}>
|
|
||||||
<button
|
|
||||||
onClick={item.command}
|
|
||||||
className="p-1.5 text-custom-text-200 hover:bg-text-custom-text-100 hover:bg-custom-background-80 active:bg-custom-background-80 rounded"
|
|
||||||
title={item.name}
|
|
||||||
>
|
|
||||||
<item.icon
|
|
||||||
className={cn("h-4 w-4 text-lg", {
|
|
||||||
"text-red-600": item.key.includes("delete"),
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,6 +0,0 @@
|
|||||||
import { clsx, type ClassValue } from "clsx";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs));
|
|
||||||
}
|
|
@ -27,26 +27,6 @@
|
|||||||
"@nivo/scatterplot": "0.80.0",
|
"@nivo/scatterplot": "0.80.0",
|
||||||
"@sentry/nextjs": "^7.36.0",
|
"@sentry/nextjs": "^7.36.0",
|
||||||
"@plane/editor": "*",
|
"@plane/editor": "*",
|
||||||
"@tiptap/extension-code-block-lowlight": "^2.0.4",
|
|
||||||
"@tiptap/extension-color": "^2.0.4",
|
|
||||||
"@tiptap/extension-gapcursor": "^2.1.7",
|
|
||||||
"@tiptap/extension-highlight": "^2.0.4",
|
|
||||||
"@tiptap/extension-horizontal-rule": "^2.0.4",
|
|
||||||
"@tiptap/extension-image": "^2.0.4",
|
|
||||||
"@tiptap/extension-link": "^2.0.4",
|
|
||||||
"@tiptap/extension-placeholder": "^2.0.4",
|
|
||||||
"@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.0.4",
|
|
||||||
"@tiptap/extension-task-list": "^2.0.4",
|
|
||||||
"@tiptap/extension-text-style": "^2.0.4",
|
|
||||||
"@tiptap/extension-underline": "^2.0.4",
|
|
||||||
"@tiptap/pm": "^2.0.4",
|
|
||||||
"@tiptap/react": "^2.0.4",
|
|
||||||
"@tiptap/starter-kit": "^2.0.4",
|
|
||||||
"@tiptap/suggestion": "^2.0.4",
|
|
||||||
"@types/lodash.debounce": "^4.0.7",
|
"@types/lodash.debounce": "^4.0.7",
|
||||||
"@types/react-datepicker": "^4.8.0",
|
"@types/react-datepicker": "^4.8.0",
|
||||||
"axios": "^1.1.3",
|
"axios": "^1.1.3",
|
||||||
@ -74,12 +54,9 @@
|
|||||||
"react-markdown": "^8.0.7",
|
"react-markdown": "^8.0.7",
|
||||||
"react-moveable": "^0.54.1",
|
"react-moveable": "^0.54.1",
|
||||||
"sharp": "^0.32.1",
|
"sharp": "^0.32.1",
|
||||||
"sonner": "^0.6.2",
|
|
||||||
"swr": "^2.1.3",
|
"swr": "^2.1.3",
|
||||||
"tailwind-merge": "^1.14.0",
|
"tailwind-merge": "^1.14.0",
|
||||||
"tiptap-markdown": "^0.8.2",
|
|
||||||
"tlds": "^1.238.0",
|
"tlds": "^1.238.0",
|
||||||
"use-debounce": "^9.0.4",
|
|
||||||
"uuid": "^9.0.0"
|
"uuid": "^9.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
Loading…
Reference in New Issue
Block a user