Add a rich text editor component for creating formatted content

Implements a `RichTextEditor` component with formatting options using React, supporting text styling, lists, links, and image insertion.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: cbacfb18-842a-4116-a907-18c0105ad8ec
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/39b5c689-6e8a-4d5a-9792-69cc81a56534/d1468a00-d93a-40b6-8ef2-3b7ba89b0f76.jpg
This commit is contained in:
mukimovd 2025-05-08 00:56:50 +00:00
parent 92d1419642
commit d3502eaa00
2 changed files with 519 additions and 0 deletions

View File

@ -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
* <RichTextEditor
* value={content}
* onChange={setContent}
* placeholder="Write your content here..."
* height="min-h-[300px]"
* showPreview
* />
* ```
*/
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<string>("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<HTMLTextAreaElement>(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("<u>", "</u>", "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, "<h1>$1</h1>");
html = html.replace(/^## (.*$)/gm, "<h2>$1</h2>");
// Convert bold
html = html.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>");
// Convert italic
html = html.replace(/\*(.*?)\*/g, "<em>$1</em>");
// Convert links
html = html.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" class="text-primary hover:underline">$1</a>');
// Convert images
html = html.replace(/!\[(.*?)\]\((.*?)\)/g, '<img src="$2" alt="$1" class="max-w-full h-auto" />');
// Convert unordered lists
html = html.replace(/^- (.*$)/gm, "<li>$1</li>");
html = html.replace(/(<li>.*<\/li>)/g, "<ul>$1</ul>");
// Convert ordered lists
html = html.replace(/^\d+\. (.*$)/gm, "<li>$1</li>");
html = html.replace(/(<li>.*<\/li>)/g, "<ol>$1</ol>");
// Convert blockquotes
html = html.replace(/^> (.*$)/gm, "<blockquote class='pl-4 border-l-4 border-gray-300 italic'>$1</blockquote>");
// Convert code blocks
html = html.replace(/```([\s\S]*?)```/g, '<pre class="bg-gray-100 dark:bg-gray-800 p-4 rounded overflow-x-auto"><code>$1</code></pre>');
// Convert line breaks
html = html.replace(/\n/g, "<br />");
return html;
};
return (
<div className={`border rounded-md ${className}`}>
{showPreview ? (
<Tabs defaultValue="write" value={activeTab} onValueChange={setActiveTab}>
<div className="border-b px-3 py-1">
<TabsList className="grid w-[200px] grid-cols-2">
<TabsTrigger value="write">Write</TabsTrigger>
<TabsTrigger value="preview">Preview</TabsTrigger>
</TabsList>
</div>
<TabsContent value="write" className="mt-0">
<EditorToolbar
onBold={handleBold}
onItalic={handleItalic}
onUnderline={handleUnderline}
onH1={handleH1}
onH2={handleH2}
onUl={handleUl}
onOl={handleOl}
onLink={() => setLinkDialogOpen(true)}
onQuote={handleQuote}
onCode={handleCode}
onImage={() => setImageDialogOpen(true)}
disabled={disabled}
/>
<Textarea
ref={editorRef}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={placeholder}
className={`resize-y border-0 focus-visible:ring-0 focus-visible:ring-transparent ${height}`}
disabled={disabled}
/>
</TabsContent>
<TabsContent value="preview" className="mt-0">
{content ? (
<div
className={`p-3 prose prose-sm md:prose dark:prose-invert max-w-none ${height} overflow-auto`}
dangerouslySetInnerHTML={{ __html: renderPreview() }}
/>
) : (
<div className={`p-3 text-gray-400 italic ${height}`}>
Nothing to preview. Start writing in the "Write" tab.
</div>
)}
</TabsContent>
</Tabs>
) : (
<>
<EditorToolbar
onBold={handleBold}
onItalic={handleItalic}
onUnderline={handleUnderline}
onH1={handleH1}
onH2={handleH2}
onUl={handleUl}
onOl={handleOl}
onLink={() => setLinkDialogOpen(true)}
onQuote={handleQuote}
onCode={handleCode}
onImage={() => setImageDialogOpen(true)}
disabled={disabled}
/>
<Textarea
ref={editorRef}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={placeholder}
className={`resize-y border-0 focus-visible:ring-0 focus-visible:ring-transparent ${height}`}
disabled={disabled}
/>
</>
)}
{/* Link Dialog */}
<Dialog open={linkDialogOpen} onOpenChange={setLinkDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Insert Link</DialogTitle>
<DialogDescription>
Add the URL and text for your link
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="url">URL</Label>
<Input
id="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
placeholder="https://example.com"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="text">Text</Label>
<Input
id="text"
value={linkText}
onChange={(e) => setLinkText(e.target.value)}
placeholder="Link text (optional)"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setLinkDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleInsertLink}>Insert Link</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Image Dialog */}
<Dialog open={imageDialogOpen} onOpenChange={setImageDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Insert Image</DialogTitle>
<DialogDescription>
Add the URL and alt text for your image
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="imageUrl">Image URL</Label>
<Input
id="imageUrl"
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
placeholder="https://example.com/image.jpg"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="imageAlt">Alt Text</Label>
<Input
id="imageAlt"
value={imageAlt}
onChange={(e) => setImageAlt(e.target.value)}
placeholder="Image description (for accessibility)"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setImageDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleInsertImage}>Insert Image</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
interface EditorToolbarProps {
onBold: () => void;
onItalic: () => void;
onUnderline: () => void;
onH1: () => void;
onH2: () => void;
onUl: () => void;
onOl: () => void;
onLink: () => void;
onQuote: () => void;
onCode: () => void;
onImage: () => void;
disabled?: boolean;
}
function EditorToolbar({
onBold,
onItalic,
onUnderline,
onH1,
onH2,
onUl,
onOl,
onLink,
onQuote,
onCode,
onImage,
disabled = false,
}: EditorToolbarProps) {
return (
<div className="flex flex-wrap gap-1 border-b p-2">
<Button
type="button"
variant="ghost"
size="sm"
className="px-2 h-8 w-8"
onClick={onBold}
disabled={disabled}
title="Bold"
>
<Bold className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="px-2 h-8 w-8"
onClick={onItalic}
disabled={disabled}
title="Italic"
>
<Italic className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="px-2 h-8 w-8"
onClick={onUnderline}
disabled={disabled}
title="Underline"
>
<Underline className="h-4 w-4" />
</Button>
<span className="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1 self-center" />
<Button
type="button"
variant="ghost"
size="sm"
className="px-2 h-8 w-8"
onClick={onH1}
disabled={disabled}
title="Heading 1"
>
<Heading1 className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="px-2 h-8 w-8"
onClick={onH2}
disabled={disabled}
title="Heading 2"
>
<Heading2 className="h-4 w-4" />
</Button>
<span className="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1 self-center" />
<Button
type="button"
variant="ghost"
size="sm"
className="px-2 h-8 w-8"
onClick={onUl}
disabled={disabled}
title="Bullet List"
>
<List className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="px-2 h-8 w-8"
onClick={onOl}
disabled={disabled}
title="Numbered List"
>
<ListOrdered className="h-4 w-4" />
</Button>
<span className="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1 self-center" />
<Button
type="button"
variant="ghost"
size="sm"
className="px-2 h-8 w-8"
onClick={onLink}
disabled={disabled}
title="Insert Link"
>
<LinkIcon className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="px-2 h-8 w-8"
onClick={onImage}
disabled={disabled}
title="Insert Image"
>
<ImageIcon className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="px-2 h-8 w-8"
onClick={onQuote}
disabled={disabled}
title="Blockquote"
>
<Quote className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="px-2 h-8 w-8"
onClick={onCode}
disabled={disabled}
title="Code Block"
>
<Code className="h-4 w-4" />
</Button>
</div>
);
}

View File

@ -0,0 +1 @@
null