import type { Editor, UseEditorOptions } from "@tiptap/react"; import { t } from "@lingui/core/macro"; import { useLingui } from "@lingui/react"; import { Trans } from "@lingui/react/macro"; import { ArrowsInSimpleIcon, ArrowsOutSimpleIcon, CodeBlockIcon, CodeSimpleIcon, ColumnsPlusLeftIcon, ColumnsPlusRightIcon, HighlighterCircleIcon, KeyReturnIcon, LinkBreakIcon, LinkIcon, ListBulletsIcon, ListNumbersIcon, MinusIcon, ParagraphIcon, PlusIcon, RowsPlusBottomIcon, RowsPlusTopIcon, TableIcon, TextAlignCenterIcon, TextAlignJustifyIcon, TextAlignLeftIcon, TextAlignRightIcon, TextBolderIcon, TextHFiveIcon, TextHFourIcon, TextHOneIcon, TextHSixIcon, TextHThreeIcon, TextHTwoIcon, TextIndentIcon, TextItalicIcon, TextOutdentIcon, TextStrikethroughIcon, TextUnderlineIcon, TrashSimpleIcon, } from "@phosphor-icons/react"; import Color from "@tiptap/extension-color"; import Highlight from "@tiptap/extension-highlight"; import { TableKit } from "@tiptap/extension-table"; import TextAlign from "@tiptap/extension-text-align"; import { TextStyle } from "@tiptap/extension-text-style"; import { EditorContent, EditorContext, useEditor, useEditorState } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { match } from "ts-pattern"; import z from "zod"; import { Button } from "@reactive-resume/ui/components/button"; import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@reactive-resume/ui/components/dialog"; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@reactive-resume/ui/components/dropdown-menu"; import { PopoverHeader, PopoverTitle, PopoverTrigger } from "@reactive-resume/ui/components/popover"; import { Toggle } from "@reactive-resume/ui/components/toggle"; import { cn } from "@reactive-resume/utils/style"; import { usePrompt } from "@/hooks/use-prompt"; import { isRTL } from "@/libs/locale"; import { ColorPicker } from "./color-picker"; const defaultTextColor = "rgba(0, 0, 0, 1)"; const extensions = [ StarterKit.configure({ heading: { levels: [1, 2, 3, 4, 5, 6], }, codeBlock: { enableTabIndentation: true, }, link: { openOnClick: false, enableClickSelection: true, defaultProtocol: "https", protocols: ["http", "https"], }, }), TextStyle, Color, Highlight.configure({ HTMLAttributes: { class: "rounded-md px-0.5 py-px", }, }), TextAlign.configure({ types: ["heading", "paragraph", "listItem"] }), TableKit.configure(), ]; type Props = UseEditorOptions & { value: string; onChange: (value: string) => void; style?: React.CSSProperties; className?: string; editorClassName?: string; }; export function RichInput({ value, onChange, style, className, editorClassName, ...options }: Props) { const { i18n } = useLingui(); const textDirection = isRTL(i18n.locale) ? "rtl" : undefined; const [isFullscreen, setIsFullscreen] = useState(false); const editor = useEditor({ ...options, extensions, textDirection, content: value, immediatelyRender: false, shouldRerenderOnTransaction: false, editorProps: { attributes: { spellcheck: "false", "data-editor": "true", "data-fullscreen": isFullscreen ? "true" : "false", class: cn( "wysiwyg group/editor overflow-y-auto p-3 pb-4", "rounded-md rounded-t-none border outline-none focus-visible:border-ring", "[td:has(.selectedCell)]:bg-primary", "data-[fullscreen=false]:max-h-[400px] data-[fullscreen=false]:min-h-[100px]", "data-[fullscreen=true]:max-h-none data-[fullscreen=true]:min-h-full", editorClassName, ), }, }, onUpdate: ({ editor }) => { onChange(editor.getHTML()); }, }); const providerValue = useMemo(() => ({ editor }), [editor]); useEffect(() => { if (!editor || editor.getHTML() === value) return; editor.commands.setContent(value, { emitUpdate: false }); }, [editor, value]); if (!editor) return null; const editorElement = (
); if (isFullscreen) { return (
{/* Placeholder to maintain layout */}
Fullscreen Editor Edit content in fullscreen mode
{editorElement}
); } return (
{editorElement}
); } function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen: boolean }) { const prompt = usePrompt(); const state = useEditorState({ editor, selector: (ctx) => { return { // Bold isBold: ctx.editor.isActive("bold") ?? false, canBold: ctx.editor.can().chain().toggleBold().run() ?? false, toggleBold: () => ctx.editor.chain().focus().toggleBold().run(), // Italic isItalic: ctx.editor.isActive("italic") ?? false, canItalic: ctx.editor.can().chain().toggleItalic().run() ?? false, toggleItalic: () => ctx.editor.chain().focus().toggleItalic().run(), // Underline isUnderline: ctx.editor.isActive("underline") ?? false, canUnderline: ctx.editor.can().chain().toggleUnderline().run() ?? false, toggleUnderline: () => ctx.editor.chain().focus().toggleUnderline().run(), // Strike isStrike: ctx.editor.isActive("strike") ?? false, canStrike: ctx.editor.can().chain().toggleStrike().run() ?? false, toggleStrike: () => ctx.editor.chain().focus().toggleStrike().run(), // Highlight isHighlight: ctx.editor.isActive("highlight") ?? false, canHighlight: ctx.editor.can().chain().toggleHighlight().run() ?? false, toggleHighlight: () => ctx.editor.chain().focus().toggleHighlight().run(), // Text Color textColor: (ctx.editor.getAttributes("textStyle").color as string | undefined) ?? null, canTextColor: ctx.editor.can().chain().setColor(defaultTextColor).run() ?? false, setTextColor: (color: string) => ctx.editor.chain().focus().setColor(color).run(), unsetTextColor: () => ctx.editor.chain().focus().unsetColor().run(), // Heading 1 isHeading1: ctx.editor.isActive("heading", { level: 1 }) ?? false, canHeading1: ctx.editor.can().chain().toggleHeading({ level: 1 }).run() ?? false, toggleHeading1: () => ctx.editor.chain().focus().toggleHeading({ level: 1 }).run(), // Heading 2 isHeading2: ctx.editor.isActive("heading", { level: 2 }) ?? false, canHeading2: ctx.editor.can().chain().toggleHeading({ level: 2 }).run() ?? false, toggleHeading2: () => ctx.editor.chain().focus().toggleHeading({ level: 2 }).run(), // Heading 3 isHeading3: ctx.editor.isActive("heading", { level: 3 }) ?? false, canHeading3: ctx.editor.can().chain().toggleHeading({ level: 3 }).run() ?? false, toggleHeading3: () => ctx.editor.chain().focus().toggleHeading({ level: 3 }).run(), // Heading 4 isHeading4: ctx.editor.isActive("heading", { level: 4 }) ?? false, canHeading4: ctx.editor.can().chain().toggleHeading({ level: 4 }).run() ?? false, toggleHeading4: () => ctx.editor.chain().focus().toggleHeading({ level: 4 }).run(), // Heading 5 isHeading5: ctx.editor.isActive("heading", { level: 5 }) ?? false, canHeading5: ctx.editor.can().chain().toggleHeading({ level: 5 }).run() ?? false, toggleHeading5: () => ctx.editor.chain().focus().toggleHeading({ level: 5 }).run(), // Heading 6 isHeading6: ctx.editor.isActive("heading", { level: 6 }) ?? false, canHeading6: ctx.editor.can().chain().toggleHeading({ level: 6 }).run() ?? false, toggleHeading6: () => ctx.editor.chain().focus().toggleHeading({ level: 6 }).run(), // Paragraph isParagraph: ctx.editor.isActive("paragraph") ?? false, canParagraph: ctx.editor.can().chain().setParagraph().run() ?? false, setParagraph: () => ctx.editor.chain().focus().setParagraph().run(), // Left Align isLeftAlign: ctx.editor.isActive({ textAlign: "left" }) ?? false, canLeftAlign: ctx.editor.can().chain().toggleTextAlign("left").run() ?? false, toggleLeftAlign: () => ctx.editor.chain().focus().toggleTextAlign("left").run(), // Center Align isCenterAlign: ctx.editor.isActive({ textAlign: "center" }) ?? false, canCenterAlign: ctx.editor.can().chain().toggleTextAlign("center").run() ?? false, toggleCenterAlign: () => ctx.editor.chain().focus().toggleTextAlign("center").run(), // Right Align isRightAlign: ctx.editor.isActive({ textAlign: "right" }) ?? false, canRightAlign: ctx.editor.can().chain().toggleTextAlign("right").run() ?? false, toggleRightAlign: () => ctx.editor.chain().focus().toggleTextAlign("right").run(), // Justify Align isJustifyAlign: ctx.editor.isActive({ textAlign: "justify" }) ?? false, canJustifyAlign: ctx.editor.can().chain().toggleTextAlign("justify").run() ?? false, toggleJustifyAlign: () => ctx.editor.chain().focus().toggleTextAlign("justify").run(), // Bullet List isBulletList: ctx.editor.isActive("bulletList") ?? false, canBulletList: ctx.editor.can().chain().toggleBulletList().run() ?? false, toggleBulletList: () => ctx.editor.chain().focus().toggleBulletList().run(), // Ordered List isOrderedList: ctx.editor.isActive("orderedList") ?? false, canOrderedList: ctx.editor.can().chain().toggleOrderedList().run() ?? false, toggleOrderedList: () => ctx.editor.chain().focus().toggleOrderedList().run(), // Outdent List Item canLiftListItem: ctx.editor.can().chain().liftListItem("listItem").run() ?? false, liftListItem: () => ctx.editor.chain().focus().liftListItem("listItem").run(), // Indent List Item canSinkListItem: ctx.editor.can().chain().sinkListItem("listItem").run() ?? false, sinkListItem: () => ctx.editor.chain().focus().sinkListItem("listItem").run(), // Link isLink: ctx.editor.isActive("link") ?? false, setLink: async () => { const url = await prompt(t`Please enter the URL you want to link to:`, { defaultValue: "https://", }); if (!url || url.trim() === "") { ctx.editor.chain().focus().unsetLink().run(); return; } if (!z.url({ protocol: /^https?$/ }).safeParse(url).success) { toast.error(t`The URL you entered is not valid.`, { description: t`Valid URLs must start with http:// or https://.`, }); return; } ctx.editor.chain().focus().setLink({ href: url, target: "_blank", rel: "noopener nofollow" }).run(); }, unsetLink: () => ctx.editor.chain().focus().unsetLink().run(), // Inline Code isInlineCode: ctx.editor.isActive("code") ?? false, canInlineCode: ctx.editor.can().chain().toggleCode().run() ?? false, toggleInlineCode: () => ctx.editor.chain().focus().toggleCode().run(), // Code Block isCodeBlock: ctx.editor.isActive("codeBlock") ?? false, canCodeBlock: ctx.editor.can().chain().toggleCodeBlock().run() ?? false, toggleCodeBlock: () => ctx.editor.chain().focus().toggleCodeBlock().run(), // Table insertTable: () => ctx.editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(), canInsertTable: ctx.editor.can().chain().insertTable().run() ?? false, addColumnBefore: () => ctx.editor.chain().focus().addColumnBefore().run(), canAddColumnBefore: ctx.editor.can().chain().addColumnBefore().run() ?? false, addColumnAfter: () => ctx.editor.chain().focus().addColumnAfter().run(), canAddColumnAfter: ctx.editor.can().chain().addColumnAfter().run() ?? false, addRowBefore: () => ctx.editor.chain().focus().addRowBefore().run(), canAddRowBefore: ctx.editor.can().chain().addRowBefore().run() ?? false, addRowAfter: () => ctx.editor.chain().focus().addRowAfter().run(), canAddRowAfter: ctx.editor.can().chain().addRowAfter().run() ?? false, deleteColumn: () => ctx.editor.chain().focus().deleteColumn().run(), canDeleteColumn: ctx.editor.can().chain().deleteColumn().run() ?? false, deleteRow: () => ctx.editor.chain().focus().deleteRow().run(), canDeleteRow: ctx.editor.can().chain().deleteRow().run() ?? false, deleteTable: () => ctx.editor.chain().focus().deleteTable().run(), canDeleteTable: ctx.editor.can().chain().deleteTable().run() ?? false, // Hard Break setHardBreak: () => ctx.editor.chain().focus().setHardBreak().run(), // Horizontal Rule setHorizontalRule: () => ctx.editor.chain().focus().setHorizontalRule().run(), }; }, }); return (
A } /> } >
A
Text Color Choose a preset or custom shade.
{match(state) .with({ isParagraph: true }, () => ) .with({ isHeading1: true }, () => ) .with({ isHeading2: true }, () => ) .with({ isHeading3: true }, () => ) .with({ isHeading4: true }, () => ) .with({ isHeading5: true }, () => ) .with({ isHeading6: true }, () => ) .otherwise(() => ( ))} } /> Paragraph Heading 1 Heading 2 Heading 3 Heading 4 Heading 5 Heading 6 {match(state) .with({ isLeftAlign: true }, () => ) .with({ isCenterAlign: true }, () => ) .with({ isRightAlign: true }, () => ) .with({ isJustifyAlign: true }, () => ) .otherwise(() => ( ))} } /> Left Align Center Align Right Align Justify Align
{state.isLink ? ( ) : ( )} } /> Insert Table Add Column Before Add Column After Add Row Before Add Row After Delete Column Delete Row Delete Table
); }