Skip to content

Commit

Permalink
rich text editor
Browse files Browse the repository at this point in the history
  • Loading branch information
in-mai-space committed Jun 11, 2024
1 parent 2c32868 commit 899e624
Show file tree
Hide file tree
Showing 18 changed files with 12,892 additions and 788 deletions.
10,219 changes: 10,219 additions & 0 deletions frontend/dashboard/package-lock.json

Large diffs are not rendered by default.

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, ListOrdered, UnderlineIcon } from 'lucide-react';

Check notice

Code scanning / CodeQL

Unused variable, import, function or class Note

Unused imports ListOrdered, UnderlineIcon.

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 [json, setJSON] = useState("")

Check notice

Code scanning / CodeQL

Unused variable, import, function or class Note

Unused variable json.

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

0 comments on commit 899e624

Please sign in to comment.