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 #892

Closed
wants to merge 14 commits into from
Closed
Show file tree
Hide file tree
Changes from 10 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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ node_modules
backend/tests/api/__debug_*
.env.prod

frontend/mobile/ios/
# frontend/mobile/ios/
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we add this line back in?

frontend/mobile/android/
tmp/
ios
Expand Down
38 changes: 38 additions & 0 deletions frontend/dashboard/components/editor/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/components/editor/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/components/editor/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/components/editor/hyperlink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/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="link"
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/components/editor/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/components/editor/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 "@/components/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="link"
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="link"
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="link" 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="link" 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="link" 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="link" 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="link" 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="link" 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="link"
onClick={() => {
editor.chain().focus().unsetLink().run();
}}
disabled={!editor.isActive('link') || !editor.isEditable}
className="p-1.5"
>
<Unlink />
</Button>
<Divider />
<Button variant="link"
size="link" disabled={!editor.isEditable} className="p-1.5" onClick={() => editor.chain().focus().undo().run()}>
<Undo />
</Button>
<Button variant="link"
size="link" 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>
)
}
3 changes: 2 additions & 1 deletion frontend/dashboard/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const buttonVariants = cva(
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
link: ""
},
},
defaultVariants: {
Expand Down Expand Up @@ -53,4 +54,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
)
Button.displayName = "Button"

export { Button, buttonVariants }
export { Button, buttonVariants }
31 changes: 31 additions & 0 deletions frontend/dashboard/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
Loading