mirror of
https://github.com/SamyRai/tercul-frontend.git
synced 2025-12-27 00:11:35 +00:00
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:
parent
92d1419642
commit
d3502eaa00
@ -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 = ``;
|
||||
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>
|
||||
);
|
||||
}
|
||||
1
client/src/pages/tests/ComponentTest.tsx
Normal file
1
client/src/pages/tests/ComponentTest.tsx
Normal file
@ -0,0 +1 @@
|
||||
null
|
||||
Loading…
Reference in New Issue
Block a user