Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: rich text editor #996

Merged
merged 3 commits into from
Jun 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion frontend/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"@reduxjs/toolkit": "^2.2.4",
"@tiptap/extension-link": "^2.4.0",
"@tiptap/extension-underline": "^2.4.0",
"@tiptap/react": "^2.4.0",
"@tiptap/starter-kit": "^2.4.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
Expand All @@ -44,8 +48,8 @@
"@types/jest": "^29.5.12",
"@types/node": "^20",
"@types/react": "^18",
"@types/redux-persist": "^4.3.1",
"@types/react-dom": "^18",
"@types/redux-persist": "^4.3.1",
"autoprefixer": "^10.4.19",
"eslint": "^8",
"eslint-config-next": "14.2.3",
Expand Down
2 changes: 2 additions & 0 deletions frontend/dashboard/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import Tiptap from "../components/dashboard/editor";
import { UserState, setUser } from "../store/slices/userSlice";
import { useAppDispatch, useAppSelector } from "../store/store";

Expand All @@ -20,6 +21,7 @@ export default function Home() {
<button onClick={() => dispatch(setUser(user))}>Press Me</button>
SAC Dashboard
{clubId}
<Tiptap />
</main>
);
}
38 changes: 38 additions & 0 deletions frontend/dashboard/src/components/dashboard/bubbleMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {BubbleMenu} from '@tiptap/react';
import { Editor } from '@tiptap/react'
import { Bold, Heading1, Heading2, Italic, ListIcon } from 'lucide-react';

interface BubbleMenuProps {
editor: Editor | null;
}

export const EditorBubbleMenu: React.FC<BubbleMenuProps> = ({ editor }) => {
if (!editor) {
return null
}

return (
<BubbleMenu className="flex items-center flex-row bg-gray-300 rounded-md space-x-1 p-2" editor={editor} tippyOptions={{ duration: 100 }}>
<div className={editor.isActive('bold') ? 'bg-gray-400 rounded-md p-1.5' : 'p-1.5'}
onClick={() => editor.chain().focus().toggleBold().run()}>
<Bold size={20} />
</div>
<div className={editor.isActive('italic') ? 'bg-gray-400 rounded-md p-1.5' : 'p-1.5'}
onClick={() => editor.chain().focus().toggleItalic().run()}>
<Italic size={20}/>
</div>
<div className={editor.isActive('heading', { level: 1 }) ? 'bg-gray-400 rounded-md p-1.5' : 'p-1.5'}
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}>
<Heading1 size={20} />
</div>
<div className={editor.isActive('heading', { level: 2 }) ? 'bg-gray-400 rounded-md p-1.5' : 'p-1.5'}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>
<Heading2 size={20}/>
</div>
<div className={editor.isActive('bulletList') ? 'bg-gray-400 rounded-md p-1.5' : 'p-1.5'}
onClick={() => editor.chain().focus().toggleBulletList().run()}>
<ListIcon size={20}/>
</div>
</BubbleMenu>
)
}
5 changes: 5 additions & 0 deletions frontend/dashboard/src/components/dashboard/divider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const Divider = () => {
return (
<div className="bg-black h-5 w-0.5 mx-5" />
)
}
36 changes: 36 additions & 0 deletions frontend/dashboard/src/components/dashboard/editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"use client"
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { Toolbar } from './toolbar'
import Underline from '@tiptap/extension-underline';
import { Link } from '@tiptap/extension-link';
import { BubbleMenu } from '@tiptap/extension-bubble-menu';
import { EditorBubbleMenu } from './bubbleMenu';
import React from 'react';
import './styles.css';

const Tiptap = () => {
const editor = useEditor({
autofocus: true,
editable: false,
extensions: [
StarterKit, Underline,
BubbleMenu,
Link.configure({
openOnClick: true,
autolink: true,
}),
],
content: {"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"type":"text","text":"Hello World"}]},{"type":"heading","attrs":{"level":2},"content":[{"type":"text","marks":[{"type":"bold"},{"type":"italic"},{"type":"strike"},{"type":"underline"}],"text":"This is generate"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Task 1"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Task 2"}]}]}]},{"type":"orderedList","attrs":{"start":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Task 3"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Task 4"}]}]}]},{"type":"paragraph","content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"https://generatenu.com","target":"_blank","rel":"noopener noreferrer nofollow","class":null}}],"text":"link"}]}]},
})

return (
<>
<Toolbar editor={editor}/>
{editor && <EditorBubbleMenu editor={editor}/>}
<EditorContent className="h-15 pl-10 p-5 rounded-md bg-slate-200" editor={editor} />
</>
)
}

export default Tiptap;
104 changes: 104 additions & 0 deletions frontend/dashboard/src/components/dashboard/hyperlink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Editor } from "@tiptap/react";
import { Link } from "lucide-react";
import { useEffect } from "react";
import { z } from "zod";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

interface HyperLinkProps {
editor: Editor | null;
disabled: boolean;
}

const linkSchema = z.object({
link: z.string().url("Invalid URL").optional(),
});

type LinkData = z.infer<typeof linkSchema>;

export const HyperlinkButton: React.FC<HyperLinkProps> = ({ editor, disabled }) => {
const {
control,
handleSubmit,
setValue,
formState: { errors },
} = useForm<LinkData>({
resolver: zodResolver(linkSchema),
defaultValues: {
link: editor?.getAttributes("link").href || "", // Use optional chaining here
},
});

useEffect(() => {
if (!editor) return;

const updateLink = () => {
const currentLink = editor.getAttributes("link").href;
setValue("link", currentLink || "");
};

editor.on("transaction", updateLink);
return () => {
editor.off("transaction", updateLink);
};
}, [editor, setValue]);

const setLinkInEditor = (data: LinkData) => {
const url = data.link;
if (!url) {
editor?.chain().focus().unsetLink().run();
return;
}
editor?.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
};

const onSubmit = handleSubmit((data) => {
setLinkInEditor(data);
});

return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="link"
className={editor?.isActive("link") && editor?.isEditable ? "bg-slate-300 rounded-md p-1.5" : "p-1.5"}
size="icon"
disabled={disabled}
>
<Link />
</Button>
</PopoverTrigger>
<PopoverContent className="w-76">
<form onSubmit={onSubmit} className="flex flex-row items-center space-x-5">
<div className="flex flex-row space-x-2">
<div className="flex flex-col items-top">
<Controller
name="link"
control={control}
render={({ field }) => (
<Input
id="link"
placeholder="Enter a link"
{...field}
onKeyDown={(e) => {
if (e.key === "Enter") {
onSubmit();
}
}}
/>
)}
/>
{errors.link && (
<p className="text-sm pt-1 text-red-400">{errors.link.message}</p>
)}
</div>
<Button type="submit">Submit</Button>
</div>
</form>
</PopoverContent>
</Popover>
);
};
20 changes: 20 additions & 0 deletions frontend/dashboard/src/components/dashboard/styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
.tiptap {
> * + * {
margin-top: 0.75em;
}

a {
color: revert;
text-decoration: revert;
}

ul, ol {
list-style: revert;
padding-left: revert;
}

h1, h2 {
font-size: revert;
font-weight: revert;
}
}
101 changes: 101 additions & 0 deletions frontend/dashboard/src/components/dashboard/toolbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { Editor } from '@tiptap/react'
import {Bold, Italic, Strikethrough,
Heading1, Heading2, ListOrdered,
Undo, Redo, Unlink,
ListIcon, UnderlineIcon} from 'lucide-react';
import { useState } from 'react';
import { Divider } from './divider';
import { HyperlinkButton } from './hyperlink';
import { Button } from "../ui/button";

interface ToolbarProps {
editor: Editor | null;
}

export const Toolbar: React.FC<ToolbarProps> = ({ editor }) => {
const [, setJSON] = useState("")

if (!editor) {
return null
}

return (
<div className="flex flex-row bg-slate-200 p-2 rounded-md items-center mb-2 justify-between flex-wrap">
<div className="ml-1 flex flex-row items-center flex-wrap space-x-1">
<Button onClick={() => editor.chain().focus().toggleBold().run()}
disabled={!editor.isEditable}
variant="link"
size="icon"
className={editor.isActive('bold') && editor.isEditable ? 'bg-slate-300 rounded-md p-1.5' : 'p-1.5'}>
<Bold />
</Button>
<Button disabled={!editor.isEditable}
variant="link"
size="icon"
onClick={() => editor.chain().focus().toggleItalic().run()}
className={editor.isActive('italic') && editor.isEditable ? 'bg-slate-300 rounded-md p-1.5' : 'p-1.5'}>
<Italic />
</Button>
<Button variant="link"
size="icon" disabled={!editor.isEditable} onClick={() => editor.chain().focus().toggleStrike().run()}
className={editor.isActive('strike') && editor.isEditable ? 'bg-slate-300 rounded-md p-1.5' : 'p-1.5'}>
<Strikethrough />
</Button>
<Button variant="link"
size="icon" disabled={!editor.isEditable} onClick={() => editor.chain().focus().toggleUnderline().run()}
className={editor.isActive('underline') && editor.isEditable ? 'bg-slate-300 rounded-md p-1.5' : 'p-1.5'}>
<UnderlineIcon />
</Button>
<Divider />
<Button variant="link"
size="icon" disabled={!editor.isEditable} onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={editor.isActive('heading', { level: 1 }) && editor.isEditable ? 'bg-slate-300 rounded-md p-1.5' : 'p-1.5'}>
<Heading1 />
</Button>
<Button variant="link"
size="icon" disabled={!editor.isEditable} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={editor.isActive('heading', { level: 2 }) && editor.isEditable ? 'bg-slate-300 rounded-md p-1.5' : 'p-1.5'}>
<Heading2 />
</Button>
<Divider />
<Button variant="link"
size="icon" disabled={!editor.isEditable} onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editor.isActive('bulletList') && editor.isEditable ? 'bg-slate-300 rounded-md p-1.5' : 'p-1.5'}>
<ListIcon />
</Button>
<Button variant="link"
size="icon" disabled={!editor.isEditable} onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={editor.isActive('orderedList') && editor.isEditable ? 'bg-slate-300 rounded-md p-1.5' : 'p-1.5'}>
<ListOrdered />
</Button>
<HyperlinkButton disabled={!editor.isEditable} editor={editor}/>
<Button
variant="link"
size="icon"
onClick={() => {
editor.chain().focus().unsetLink().run();
}}
disabled={!editor.isActive('link') || !editor.isEditable}
className="p-1.5"
>
<Unlink />
</Button>
<Divider />
<Button variant="link"
size="icon" disabled={!editor.isEditable} className="p-1.5" onClick={() => editor.chain().focus().undo().run()}>
<Undo />
</Button>
<Button variant="link"
size="icon" disabled={!editor.isEditable} className="p-1.5" onClick={() => editor.chain().focus().redo().run()}>
<Redo />
</Button>
</div>

<Button onClick={() => {
setJSON(JSON.stringify(editor.getJSON()));
editor.setEditable(!editor.isEditable);
}}
className="px-4 py-1 bg-black rounded-md text-white text-sm">{editor.isEditable ? "Save" : "Edit"}</Button>
</div>
)
}
31 changes: 31 additions & 0 deletions frontend/dashboard/src/components/ui/popover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"use client"

import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"

import { cn } from "@/lib/utils"

const Popover = PopoverPrimitive.Root

const PopoverTrigger = PopoverPrimitive.Trigger

const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName

export { Popover, PopoverTrigger, PopoverContent }
Loading