Files
Reactive-Resume/libs/ui/src/components/rich-input.tsx
Amruth Pillai d0a07686a5 Merge pull request #1997 from 2368883029/main
Fix(client): Fix Unintentional Form Submission on Rich-input Toolbar Actions
2025-01-12 16:41:35 +01:00

492 lines
14 KiB
TypeScript

import { zodResolver } from "@hookform/resolvers/zod";
import {
ArrowClockwise,
ArrowCounterClockwise,
Code as CodeIcon,
CodeBlock as CodeBlockIcon,
HighlighterCircle,
Image as ImageIcon,
KeyReturn,
LinkSimple,
ListBullets,
ListNumbers,
Minus,
Paragraph as ParagraphIcon,
TextAlignCenter,
TextAlignJustify,
TextAlignLeft,
TextAlignRight,
TextAUnderline,
TextB,
TextHOne,
TextHThree,
TextHTwo,
TextIndent,
TextItalic,
TextOutdent,
TextStrikethrough,
} from "@phosphor-icons/react";
import { PopoverTrigger } from "@radix-ui/react-popover";
import { cn } from "@reactive-resume/utils";
import { Highlight } from "@tiptap/extension-highlight";
import { Image } from "@tiptap/extension-image";
import { Link } from "@tiptap/extension-link";
import { TextAlign } from "@tiptap/extension-text-align";
import { Underline } from "@tiptap/extension-underline";
import { Editor, EditorContent, EditorContentProps, useEditor } from "@tiptap/react";
import { StarterKit } from "@tiptap/starter-kit";
import { forwardRef, useCallback } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "./button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "./form";
import { Input } from "./input";
import { Popover, PopoverContent } from "./popover";
import { Skeleton } from "./skeleton";
import { Toggle } from "./toggle";
import { Tooltip } from "./tooltip";
const InsertImageFormSchema = z.object({
src: z.string(),
alt: z.string().optional(),
});
type InsertImageFormValues = z.infer<typeof InsertImageFormSchema>;
type InsertImageProps = {
onInsert: (value: InsertImageFormValues) => void;
};
const InsertImageForm = ({ onInsert }: InsertImageProps) => {
const form = useForm<InsertImageFormValues>({
resolver: zodResolver(InsertImageFormSchema),
defaultValues: { src: "", alt: "" },
});
const onSubmit = (values: InsertImageFormValues) => {
onInsert(values);
form.reset();
};
return (
<Form {...form}>
<form className="space-y-3"
onSubmit={(e) => {
e.stopPropagation();
e.preventDefault();
form.handleSubmit(onSubmit)();
}}>
<p className="prose prose-sm prose-zinc dark:prose-invert">
Insert an image from an external URL and use it on your resume.
</p>
<FormField
name="src"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>URL</FormLabel>
<FormControl>
<Input placeholder="https://..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="alt"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
</FormItem>
)}
/>
<div className="!mt-5 ml-auto max-w-fit">
<Button type="submit" variant="secondary" size="sm">
Insert Image
</Button>
</div>
</form>
</Form>
);
};
const Toolbar = ({ editor }: { editor: Editor }) => {
const setLink = useCallback(() => {
const previousUrl = editor.getAttributes("link").href;
const url = window.prompt("URL", previousUrl);
// cancelled
if (url === null) {
return;
}
// empty
if (url === "") {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
return;
}
// update link
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
}, [editor]);
return (
<div className="flex flex-wrap gap-0.5 border p-1">
<Tooltip content="Bold">
<Toggle
size="sm"
pressed={editor.isActive("bold")}
disabled={!editor.can().chain().toggleBold().run()}
onPressedChange={() => editor.chain().focus().toggleBold().run()}
>
<TextB />
</Toggle>
</Tooltip>
<Tooltip content="Italic">
<Toggle
size="sm"
pressed={editor.isActive("italic")}
disabled={!editor.can().chain().focus().toggleItalic().run()}
onPressedChange={() => editor.chain().focus().toggleItalic().run()}
>
<TextItalic />
</Toggle>
</Tooltip>
<Tooltip content="Strikethrough">
<Toggle
size="sm"
pressed={editor.isActive("strike")}
disabled={!editor.can().chain().focus().toggleStrike().run()}
onPressedChange={() => editor.chain().focus().toggleStrike().run()}
>
<TextStrikethrough />
</Toggle>
</Tooltip>
<Tooltip content="Underline">
<Toggle
size="sm"
pressed={editor.isActive("underline")}
disabled={!editor.can().chain().focus().toggleUnderline().run()}
onPressedChange={() => editor.chain().focus().toggleUnderline().run()}
>
<TextAUnderline />
</Toggle>
</Tooltip>
<Tooltip content="Highlight">
<Toggle
size="sm"
pressed={editor.isActive("highlight")}
disabled={!editor.can().chain().focus().toggleHighlight().run()}
onPressedChange={() => editor.chain().focus().toggleHighlight().run()}
>
<HighlighterCircle />
</Toggle>
</Tooltip>
<Tooltip content="Hyperlink">
<Button type="button" size="sm" variant="ghost" className="px-2" onClick={setLink}>
<LinkSimple />
</Button>
</Tooltip>
<Tooltip content="Inline Code">
<Toggle
size="sm"
pressed={editor.isActive("code")}
disabled={!editor.can().chain().focus().toggleCode().run()}
onPressedChange={() => editor.chain().focus().toggleCode().run()}
>
<CodeIcon />
</Toggle>
</Tooltip>
<Tooltip content="Code Block">
<Toggle
size="sm"
pressed={editor.isActive("codeBlock")}
disabled={!editor.can().chain().focus().toggleCodeBlock().run()}
onPressedChange={() => editor.chain().focus().toggleCodeBlock().run()}
>
<CodeBlockIcon />
</Toggle>
</Tooltip>
<Tooltip content="Heading 1">
<Toggle
size="sm"
pressed={editor.isActive("heading", { level: 1 })}
disabled={!editor.can().chain().focus().toggleHeading({ level: 1 }).run()}
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
>
<TextHOne />
</Toggle>
</Tooltip>
<Tooltip content="Heading 2">
<Toggle
size="sm"
pressed={editor.isActive("heading", { level: 2 })}
disabled={!editor.can().chain().focus().toggleHeading({ level: 2 }).run()}
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
>
<TextHTwo />
</Toggle>
</Tooltip>
<Tooltip content="Heading 3">
<Toggle
size="sm"
pressed={editor.isActive("heading", { level: 3 })}
disabled={!editor.can().chain().focus().toggleHeading({ level: 3 }).run()}
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
>
<TextHThree />
</Toggle>
</Tooltip>
<Tooltip content="Paragraph">
<Toggle
size="sm"
pressed={editor.isActive("paragraph")}
onPressedChange={() => editor.chain().focus().setParagraph().run()}
>
<ParagraphIcon />
</Toggle>
</Tooltip>
<Tooltip content="Align Left">
<Toggle
size="sm"
pressed={editor.isActive({ textAlign: "left" })}
disabled={!editor.can().chain().focus().setTextAlign("left").run()}
onPressedChange={() => editor.chain().focus().setTextAlign("left").run()}
>
<TextAlignLeft />
</Toggle>
</Tooltip>
<Tooltip content="Align Center">
<Toggle
size="sm"
pressed={editor.isActive({ textAlign: "center" })}
disabled={!editor.can().chain().focus().setTextAlign("center").run()}
onPressedChange={() => editor.chain().focus().setTextAlign("center").run()}
>
<TextAlignCenter />
</Toggle>
</Tooltip>
<Tooltip content="Align Right">
<Toggle
size="sm"
pressed={editor.isActive({ textAlign: "right" })}
disabled={!editor.can().chain().focus().setTextAlign("right").run()}
onPressedChange={() => editor.chain().focus().setTextAlign("right").run()}
>
<TextAlignRight />
</Toggle>
</Tooltip>
<Tooltip content="Align Justify">
<Toggle
size="sm"
pressed={editor.isActive({ textAlign: "justify" })}
disabled={!editor.can().chain().focus().setTextAlign("justify").run()}
onPressedChange={() => editor.chain().focus().setTextAlign("justify").run()}
>
<TextAlignJustify />
</Toggle>
</Tooltip>
<Tooltip content="Bullet List">
<Toggle
size="sm"
pressed={editor.isActive("bulletList")}
disabled={!editor.can().chain().focus().toggleBulletList().run()}
onPressedChange={() => editor.chain().focus().toggleBulletList().run()}
>
<ListBullets />
</Toggle>
</Tooltip>
<Tooltip content="Numbered List">
<Toggle
size="sm"
pressed={editor.isActive("orderedList")}
disabled={!editor.can().chain().focus().toggleOrderedList().run()}
onPressedChange={() => editor.chain().focus().toggleOrderedList().run()}
>
<ListNumbers />
</Toggle>
</Tooltip>
<Tooltip content="Outdent">
<Button
size="sm"
variant="ghost"
className="px-2"
disabled={!editor.can().chain().focus().liftListItem("listItem").run()}
onClick={() => editor.chain().focus().liftListItem("listItem").run()}
>
<TextOutdent />
</Button>
</Tooltip>
<Tooltip content="Indent">
<Button
size="sm"
variant="ghost"
className="px-2"
disabled={!editor.can().chain().focus().sinkListItem("listItem").run()}
onClick={() => editor.chain().focus().sinkListItem("listItem").run()}
>
<TextIndent />
</Button>
</Tooltip>
<Popover>
<Tooltip content="Insert Image">
<PopoverTrigger asChild>
<Button size="sm" variant="ghost" className="px-2">
<ImageIcon />
</Button>
</PopoverTrigger>
</Tooltip>
<PopoverContent className="w-80">
<InsertImageForm onInsert={(props) => editor.chain().focus().setImage(props).run()} />
</PopoverContent>
</Popover>
<Tooltip content="Insert Break Line">
<Button
size="sm"
variant="ghost"
className="px-2"
type="button"
disabled={!editor.can().chain().focus().setHardBreak().run()}
onClick={() => editor.chain().focus().setHardBreak().run()}
>
<KeyReturn />
</Button>
</Tooltip>
<Tooltip content="Insert Horizontal Rule">
<Button
size="sm"
variant="ghost"
className="px-2"
type="button"
disabled={!editor.can().chain().focus().setHorizontalRule().run()}
onClick={() => editor.chain().focus().setHorizontalRule().run()}
>
<Minus />
</Button>
</Tooltip>
<Tooltip content="Undo">
<Button
size="sm"
variant="ghost"
className="px-2"
disabled={!editor.can().undo()}
onClick={() => editor.chain().focus().undo().run()}
>
<ArrowCounterClockwise />
</Button>
</Tooltip>
<Tooltip content="Redo">
<Button
size="sm"
variant="ghost"
className="px-2"
disabled={!editor.can().redo()}
onClick={() => editor.chain().focus().redo().run()}
>
<ArrowClockwise />
</Button>
</Tooltip>
</div>
);
};
type RichInputProps = {
content?: string;
onChange?: (value: string) => void;
hideToolbar?: boolean;
className?: string;
editorClassName?: string;
footer?: (editor: Editor) => React.ReactNode;
} & Omit<EditorContentProps, "ref" | "editor" | "content" | "value" | "onChange" | "className">;
export const RichInput = forwardRef<Editor, RichInputProps>(
(
{ content, onChange, footer, hideToolbar = false, className, editorClassName, ...props },
_ref,
) => {
const editor = useEditor({
extensions: [
StarterKit,
Image,
Underline,
Highlight,
TextAlign.configure({ types: ["heading", "paragraph"] }),
Link.extend({ inclusive: false }).configure({ openOnClick: false }),
],
editorProps: {
attributes: {
class: cn(
"prose prose-sm prose-zinc max-h-[200px] max-w-none overflow-y-scroll dark:prose-invert focus:outline-none [&_*]:my-2",
editorClassName,
),
},
},
content,
parseOptions: { preserveWhitespace: "full" },
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
});
if (!editor) {
return (
<div className="space-y-2">
<Skeleton className={cn("h-[42px] w-full", hideToolbar && "hidden")} />
<Skeleton className="h-[90px] w-full" />
</div>
);
}
return (
<div>
{!hideToolbar && <Toolbar editor={editor} />}
<EditorContent
editor={editor}
className={cn(
"grid min-h-[160px] w-full rounded-sm border bg-transparent px-3 py-2 text-sm placeholder:opacity-80 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50",
hideToolbar && "pt-2",
className,
)}
{...props}
/>
{footer?.(editor)}
</div>
);
},
);
RichInput.displayName = "RichInput";