diff --git a/client/src/components/ui/rich-text-editor.tsx b/client/src/components/ui/rich-text-editor.tsx index e69de29..a368924 100644 --- a/client/src/components/ui/rich-text-editor.tsx +++ b/client/src/components/ui/rich-text-editor.tsx @@ -0,0 +1,518 @@ +import { useState, useEffect, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Bold, + Italic, + Underline, + Heading1, + Heading2, + List, + ListOrdered, + Link as LinkIcon, + Quote, + Code, + Image as ImageIcon +} from "lucide-react"; +import { Textarea } from "@/components/ui/textarea"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; + +export interface RichTextEditorProps { + /** + * Initial content value + */ + value?: string; + /** + * Event handler called when content changes + */ + onChange?: (value: string) => void; + /** + * Placeholder text when editor is empty + */ + placeholder?: string; + /** + * Height of the editor + */ + height?: string; + /** + * Whether to show the preview tab + */ + showPreview?: boolean; + /** + * CSS class to apply to the editor container + */ + className?: string; + /** + * Whether the editor is disabled + */ + disabled?: boolean; +} + +/** + * Rich text editor component for content creation + * + * Features: + * - Basic formatting (bold, italic, underline) + * - Headings (h1, h2) + * - Lists (ordered, unordered) + * - Links + * - Images + * - Code blocks + * - Quotes + * - Preview mode + * + * @example + * ```tsx + * + * ``` + */ +export function RichTextEditor({ + value = "", + onChange, + placeholder = "Write your content here...", + height = "min-h-[300px]", + showPreview = true, + className = "", + disabled = false, +}: RichTextEditorProps) { + const [content, setContent] = useState(value); + const [activeTab, setActiveTab] = useState("write"); + const [linkDialogOpen, setLinkDialogOpen] = useState(false); + const [imageDialogOpen, setImageDialogOpen] = useState(false); + const [linkUrl, setLinkUrl] = useState(""); + const [linkText, setLinkText] = useState(""); + const [imageUrl, setImageUrl] = useState(""); + const [imageAlt, setImageAlt] = useState(""); + const editorRef = useRef(null); + + useEffect(() => { + if (value !== content) { + setContent(value); + } + }, [value]); + + useEffect(() => { + if (onChange && content !== value) { + onChange(content); + } + }, [content, onChange, value]); + + // Insert text at cursor position or replace selection + const insertTextAtCursor = ( + textBefore: string, + textAfter: string = "", + defaultText: string = "" + ) => { + if (!editorRef.current) return; + + const textarea = editorRef.current; + const selectionStart = textarea.selectionStart; + const selectionEnd = textarea.selectionEnd; + const selectedText = textarea.value.substring(selectionStart, selectionEnd); + const textToInsert = selectedText || defaultText; + + const newText = + textarea.value.substring(0, selectionStart) + + textBefore + + textToInsert + + textAfter + + textarea.value.substring(selectionEnd); + + setContent(newText); + + // Focus back on textarea and position cursor after inserted text + setTimeout(() => { + textarea.focus(); + const newCursorPos = selectionStart + textBefore.length + textToInsert.length + textAfter.length; + textarea.setSelectionRange(newCursorPos, newCursorPos); + }, 0); + }; + + // Format handlers + const handleBold = () => insertTextAtCursor("**", "**", "bold text"); + const handleItalic = () => insertTextAtCursor("*", "*", "italic text"); + const handleUnderline = () => insertTextAtCursor("", "", "underlined text"); + const handleH1 = () => insertTextAtCursor("# ", "", "Heading 1"); + const handleH2 = () => insertTextAtCursor("## ", "", "Heading 2"); + const handleUl = () => insertTextAtCursor("- ", "", "List item"); + const handleOl = () => insertTextAtCursor("1. ", "", "List item"); + const handleQuote = () => insertTextAtCursor("> ", "", "Blockquote"); + const handleCode = () => insertTextAtCursor("```\n", "\n```", "code"); + + const handleInsertLink = () => { + if (!linkUrl) return; + const linkMarkdown = `[${linkText || linkUrl}](${linkUrl})`; + insertTextAtCursor(linkMarkdown, "", ""); + setLinkDialogOpen(false); + setLinkUrl(""); + setLinkText(""); + }; + + const handleInsertImage = () => { + if (!imageUrl) return; + const imageMarkdown = `![${imageAlt || "Image"}](${imageUrl})`; + insertTextAtCursor(imageMarkdown, "", ""); + setImageDialogOpen(false); + setImageUrl(""); + setImageAlt(""); + }; + + // Convert markdown to HTML for preview + const renderPreview = () => { + let html = content; + // Convert headings + html = html.replace(/^# (.*$)/gm, "

$1

"); + html = html.replace(/^## (.*$)/gm, "

$1

"); + // Convert bold + html = html.replace(/\*\*(.*?)\*\*/g, "$1"); + // Convert italic + html = html.replace(/\*(.*?)\*/g, "$1"); + // Convert links + html = html.replace(/\[(.*?)\]\((.*?)\)/g, '$1'); + // Convert images + html = html.replace(/!\[(.*?)\]\((.*?)\)/g, '$1'); + // Convert unordered lists + html = html.replace(/^- (.*$)/gm, "
  • $1
  • "); + html = html.replace(/(
  • .*<\/li>)/g, "
      $1
    "); + // Convert ordered lists + html = html.replace(/^\d+\. (.*$)/gm, "
  • $1
  • "); + html = html.replace(/(
  • .*<\/li>)/g, "
      $1
    "); + // Convert blockquotes + html = html.replace(/^> (.*$)/gm, "
    $1
    "); + // Convert code blocks + html = html.replace(/```([\s\S]*?)```/g, '
    $1
    '); + // Convert line breaks + html = html.replace(/\n/g, "
    "); + + return html; + }; + + return ( +
    + {showPreview ? ( + +
    + + Write + Preview + +
    + + + setLinkDialogOpen(true)} + onQuote={handleQuote} + onCode={handleCode} + onImage={() => setImageDialogOpen(true)} + disabled={disabled} + /> +