UNPKG

nextjs-rich-text-editor

Version:

A modern, feature-rich rich text editor for React/Next.js apps with images, tables, and more.

763 lines (672 loc) 24.2 kB
"use client" import { useState, useRef, useEffect, useCallback } from "react" import "./styles.css" export default function TextEditor({ value = "", onChange, width = "100%", height = "500px", className = "", placeholder = "Start writing your content...", }) { const editorRef = useRef(null) const fileInputRef = useRef(null) const colorInputRef = useRef(null) const bgColorInputRef = useRef(null) const [showColorPicker, setShowColorPicker] = useState(false) const [showBgColorPicker, setShowBgColorPicker] = useState(false) const [showLinkDialog, setShowLinkDialog] = useState(false) const [linkUrl, setLinkUrl] = useState("") const [linkText, setLinkText] = useState("") const [savedSelection, setSavedSelection] = useState(null) const [currentTextColor, setCurrentTextColor] = useState("#000000") const [currentBgColor, setCurrentBgColor] = useState("transparent") const [isMounted, setIsMounted] = useState(false) useEffect(() => { setIsMounted(true) }, []) useEffect(() => { if (isMounted && editorRef.current && value !== editorRef.current.innerHTML) { editorRef.current.innerHTML = value } }, [value, isMounted]) const handleInput = useCallback(() => { if (editorRef.current && onChange) { onChange(editorRef.current.innerHTML) } }, [onChange]) // Simplified paste handler const handlePaste = useCallback((e) => { const clipboardData = e.clipboardData if (!clipboardData) return // Handle image paste const items = Array.from(clipboardData.items) const imageItem = items.find((item) => item.type.startsWith("image/")) if (imageItem) { e.preventDefault() const file = imageItem.getAsFile() if (file) { insertImageFromFile(file) } return } // For text content, let the browser handle it naturally }, []) const insertImageFromFile = (file) => { if (!file || !file.type.startsWith("image/")) return const reader = new FileReader() reader.onload = (e) => { if (!e.target || !e.target.result) return const imageId = `img-${Date.now()}` const imageHTML = ` <div class="te-image-container"> <img id="${imageId}" src="${e.target.result}" class="te-image" alt="Uploaded image" style="max-width: 100%; height: auto; border-radius: 8px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); cursor: pointer;" onclick="this.classList.toggle('te-image-small'); this.classList.toggle('te-image-medium')" /> </div> ` document.execCommand("insertHTML", false, imageHTML) handleInput() } reader.readAsDataURL(file) } const execCommand = (command, value = null) => { document.execCommand(command, false, value) editorRef.current?.focus() setTimeout(handleInput, 0) } const setHeading = (level) => { if (level === "") { execCommand("formatBlock", "div") } else { execCommand("formatBlock", `h${level}`) } } const setTextColor = (color) => { if (color === "none") { execCommand("removeFormat") setCurrentTextColor("#000000") } else if (color === "custom") { colorInputRef.current?.click() } else { execCommand("foreColor", color) setCurrentTextColor(color) } setShowColorPicker(false) } const setBgColor = (color) => { if (color === "none") { execCommand("backColor", "transparent") setCurrentBgColor("transparent") } else if (color === "custom") { bgColorInputRef.current?.click() } else { execCommand("backColor", color) setCurrentBgColor(color) } setShowBgColorPicker(false) } const handleCustomTextColor = (event) => { const color = event.target.value execCommand("foreColor", color) setCurrentTextColor(color) setShowColorPicker(false) } const handleCustomBgColor = (event) => { const color = event.target.value execCommand("backColor", color) setCurrentBgColor(color) setShowBgColorPicker(false) } const saveSelection = () => { const selection = window.getSelection() if (selection.rangeCount > 0) { return selection.getRangeAt(0) } return null } const restoreSelection = (range) => { if (range) { const selection = window.getSelection() selection.removeAllRanges() selection.addRange(range) } } const handleLinkClick = () => { const selection = window.getSelection() const selectedText = selection.toString().trim() if (selectedText) { const range = saveSelection() setSavedSelection(range) setLinkText(selectedText) setLinkUrl("") setShowLinkDialog(true) } else { alert("Please select some text first to create a link.") } } const insertLink = () => { if (linkUrl && linkText && savedSelection) { restoreSelection(savedSelection) const url = linkUrl.startsWith("http") ? linkUrl : `https://${linkUrl}` const linkHTML = `<a href="${url}" target="_blank" rel="noopener noreferrer" style="color: #3b82f6; text-decoration: underline;">${linkText}</a>` execCommand("insertHTML", linkHTML) setShowLinkDialog(false) setLinkUrl("") setLinkText("") setSavedSelection(null) } } const insertTable = () => { const rows = Number.parseInt(prompt("Number of rows:", "3") || "3") const cols = Number.parseInt(prompt("Number of columns:", "3") || "3") if (rows > 0 && cols > 0) { let tableHTML = '<table class="te-table">' for (let i = 0; i < rows; i++) { tableHTML += "<tr>" for (let j = 0; j < cols; j++) { const isHeader = i === 0 const tag = isHeader ? "th" : "td" const content = isHeader ? `Header ${j + 1}` : "Cell" tableHTML += `<${tag} contenteditable="true">${content}</${tag}>` } tableHTML += "</tr>" } tableHTML += "</table>" execCommand("insertHTML", tableHTML) } } const insertImage = () => { if (fileInputRef.current) { fileInputRef.current.click() } } const handleImageUpload = (event) => { try { if (!event.target || !event.target.files || !event.target.files[0]) { return } const file = event.target.files[0] if (file && file.type.startsWith("image/")) { insertImageFromFile(file) } // Reset the input value if (event.target) { event.target.value = "" } } catch (error) { console.error("Error uploading image:", error) } } // Keyboard shortcuts const handleKeyDown = useCallback((e) => { if (e.ctrlKey || e.metaKey) { switch (e.key.toLowerCase()) { case "k": e.preventDefault() handleLinkClick() break case "b": e.preventDefault() execCommand("bold") break case "i": e.preventDefault() execCommand("italic") break case "u": e.preventDefault() execCommand("underline") break } } }, []) const predefinedColors = [ "#000000", "#374151", "#6B7280", "#9CA3AF", "#D1D5DB", "#F3F4F6", "#FFFFFF", "#EF4444", "#F97316", "#F59E0B", "#EAB308", "#84CC16", "#22C55E", "#10B981", "#06B6D4", "#0EA5E9", "#3B82F6", "#6366F1", "#8B5CF6", "#A855F7", "#D946EF", "#EC4899", "#F43F5E", ] // Icon components (same as before) const BoldIcon = () => ( <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" /> <path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" /> </svg> ) const ItalicIcon = () => ( <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <line x1="19" y1="4" x2="10" y2="4" /> <line x1="14" y1="20" x2="5" y2="20" /> <line x1="15" y1="4" x2="9" y2="20" /> </svg> ) const UnderlineIcon = () => ( <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3" /> <line x1="4" y1="21" x2="20" y2="21" /> </svg> ) const TypeIcon = () => ( <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <polyline points="4,7 4,4 20,4 20,7" /> <line x1="9" y1="20" x2="15" y2="20" /> <line x1="12" y1="4" x2="12" y2="20" /> </svg> ) const PaletteIcon = () => ( <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <circle cx="13.5" cy="6.5" r=".5" /> <circle cx="17.5" cy="10.5" r=".5" /> <circle cx="8.5" cy="7.5" r=".5" /> <circle cx="6.5" cy="12.5" r=".5" /> <path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z" /> </svg> ) const LinkIcon = () => ( <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /> </svg> ) const TableIcon = () => ( <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <path d="M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2V9M9 21H5a2 2 0 0 1-2-2V9m0 0h18" /> </svg> ) const ImageIcon = () => ( <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <rect x="3" y="3" width="18" height="18" rx="2" ry="2" /> <circle cx="8.5" cy="8.5" r="1.5" /> <polyline points="21,15 16,10 5,21" /> </svg> ) const AlignLeftIcon = () => ( <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <line x1="17" y1="10" x2="3" y2="10" /> <line x1="21" y1="6" x2="3" y2="6" /> <line x1="21" y1="14" x2="3" y2="14" /> <line x1="17" y1="18" x2="3" y2="18" /> </svg> ) const AlignCenterIcon = () => ( <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <line x1="18" y1="10" x2="6" y2="10" /> <line x1="21" y1="6" x2="3" y2="6" /> <line x1="21" y1="14" x2="3" y2="14" /> <line x1="18" y1="18" x2="6" y2="18" /> </svg> ) const AlignRightIcon = () => ( <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <line x1="21" y1="10" x2="7" y2="10" /> <line x1="21" y1="6" x2="3" y2="6" /> <line x1="21" y1="14" x2="3" y2="14" /> <line x1="21" y1="18" x2="7" y2="18" /> </svg> ) const ListIcon = () => ( <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <line x1="8" y1="6" x2="21" y2="6" /> <line x1="8" y1="12" x2="21" y2="12" /> <line x1="8" y1="18" x2="21" y2="18" /> <line x1="3" y1="6" x2="3.01" y2="6" /> <line x1="3" y1="12" x2="3.01" y2="12" /> <line x1="3" y1="18" x2="3.01" y2="18" /> </svg> ) const ListOrderedIcon = () => ( <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <line x1="10" y1="6" x2="21" y2="6" /> <line x1="10" y1="12" x2="21" y2="12" /> <line x1="10" y1="18" x2="21" y2="18" /> <path d="M4 6h1v4" /> <path d="M4 10h2" /> <path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1" /> </svg> ) const UndoIcon = () => ( <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <path d="M3 7v6h6" /> <path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13" /> </svg> ) const RedoIcon = () => ( <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <path d="M21 7v6h-6" /> <path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 2.7" /> </svg> ) const ToolbarButton = ({ onClick, children, title, active = false }) => ( <button type="button" onClick={onClick} className={`te-toolbar-btn ${active ? "te-toolbar-btn-active" : ""}`} title={title} > {children} </button> ) const ColorPicker = ({ colors, onColorSelect, show, showNone = false, showCustom = false }) => { if (!show) return null return ( <div className="te-color-picker"> {(showNone || showCustom) && ( <div className="te-color-picker-actions"> {showNone && ( <button type="button" onClick={() => onColorSelect("none")} className="te-color-action-btn"> Remove Color </button> )} {showCustom && ( <button type="button" onClick={() => onColorSelect("custom")} className="te-color-action-btn te-custom-color-btn" > Custom Color </button> )} </div> )} <div className="te-color-grid"> {colors.map((color) => ( <button key={color} type="button" onClick={() => onColorSelect(color)} className="te-color-swatch" style={{ backgroundColor: color }} title={color} /> ))} </div> </div> ) } const LinkDialog = () => { if (!showLinkDialog) return null return ( <div className="te-link-dialog-overlay"> <div className="te-link-dialog"> <div className="te-link-dialog-header"> <h3>Insert Link</h3> <button type="button" onClick={() => setShowLinkDialog(false)} className="te-link-dialog-close"> × </button> </div> <div className="te-link-dialog-content"> <div className="te-link-dialog-field"> <label>Link Text:</label> <input type="text" value={linkText} onChange={(e) => setLinkText(e.target.value)} placeholder="Enter link text" className="te-link-input" /> </div> <div className="te-link-dialog-field"> <label>URL:</label> <input type="url" value={linkUrl} onChange={(e) => setLinkUrl(e.target.value)} placeholder="https://example.com" className="te-link-input" onKeyDown={(e) => { if (e.key === "Enter" && linkUrl && linkText) { insertLink() } }} /> </div> </div> <div className="te-link-dialog-actions"> <button type="button" onClick={() => setShowLinkDialog(false)} className="te-link-btn te-link-btn-cancel"> Cancel </button> <button type="button" onClick={insertLink} className="te-link-btn te-link-btn-insert" disabled={!linkUrl || !linkText} > Insert Link </button> </div> </div> </div> ) } if (!isMounted) { return ( <div className={`te-container te-loading ${className}`} style={{ width, height: `calc(${height} + 80px)` }}> <div className="te-toolbar te-loading-toolbar"></div> <div className="te-loading-content"> <div className="te-loading-line te-loading-line-1"></div> <div className="te-loading-line te-loading-line-2"></div> <div className="te-loading-line te-loading-line-3"></div> </div> </div> ) } return ( <div className={`te-container ${className}`} style={{ width }}> {/* Toolbar */} <div className="te-toolbar"> <div className="te-toolbar-content"> {/* Format Dropdown */} <select onChange={(e) => setHeading(e.target.value)} className="te-format-select" defaultValue=""> <option value="">Normal Text</option> <option value="1">Heading 1</option> <option value="2">Heading 2</option> <option value="3">Heading 3</option> <option value="4">Heading 4</option> <option value="5">Heading 5</option> <option value="6">Heading 6</option> </select> <div className="te-separator"></div> {/* Basic Formatting */} <ToolbarButton onClick={() => execCommand("bold")} title="Bold (Ctrl+B)"> <BoldIcon /> </ToolbarButton> <ToolbarButton onClick={() => execCommand("italic")} title="Italic (Ctrl+I)"> <ItalicIcon /> </ToolbarButton> <ToolbarButton onClick={() => execCommand("underline")} title="Underline (Ctrl+U)"> <UnderlineIcon /> </ToolbarButton> <div className="te-separator"></div> {/* Text Color */} <div className="te-color-btn-container"> <ToolbarButton onClick={() => { setShowColorPicker(!showColorPicker) setShowBgColorPicker(false) }} title="Text Color" > <div className="te-color-btn-content"> <TypeIcon /> <div className="te-color-indicator" style={{ backgroundColor: currentTextColor }}></div> </div> </ToolbarButton> <ColorPicker colors={predefinedColors} onColorSelect={setTextColor} show={showColorPicker} showNone={true} showCustom={true} /> </div> {/* Background Color */} <div className="te-color-btn-container"> <ToolbarButton onClick={() => { setShowBgColorPicker(!showBgColorPicker) setShowColorPicker(false) }} title="Background Color" > <div className="te-color-btn-content"> <PaletteIcon /> <div className="te-color-indicator" style={{ backgroundColor: currentBgColor === "transparent" ? "#ffffff" : currentBgColor, backgroundImage: currentBgColor === "transparent" ? "linear-gradient(45deg, #ccc 25%, transparent 25%), linear-gradient(-45deg, #ccc 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #ccc 75%), linear-gradient(-45deg, transparent 75%, #ccc 75%)" : "none", backgroundSize: currentBgColor === "transparent" ? "4px 4px" : "auto", backgroundPosition: currentBgColor === "transparent" ? "0 0, 0 2px, 2px -2px, -2px 0px" : "auto", }} ></div> </div> </ToolbarButton> <ColorPicker colors={predefinedColors} onColorSelect={setBgColor} show={showBgColorPicker} showNone={true} showCustom={true} /> </div> <div className="te-separator"></div> {/* Link */} <ToolbarButton onClick={handleLinkClick} title="Insert Link (Ctrl+K)"> <LinkIcon /> </ToolbarButton> <div className="te-separator"></div> {/* Alignment */} <ToolbarButton onClick={() => execCommand("justifyLeft")} title="Align Left"> <AlignLeftIcon /> </ToolbarButton> <ToolbarButton onClick={() => execCommand("justifyCenter")} title="Align Center"> <AlignCenterIcon /> </ToolbarButton> <ToolbarButton onClick={() => execCommand("justifyRight")} title="Align Right"> <AlignRightIcon /> </ToolbarButton> <div className="te-separator"></div> {/* Lists */} <ToolbarButton onClick={() => execCommand("insertUnorderedList")} title="Bullet List"> <ListIcon /> </ToolbarButton> <ToolbarButton onClick={() => execCommand("insertOrderedList")} title="Numbered List"> <ListOrderedIcon /> </ToolbarButton> <div className="te-separator"></div> {/* Insert Options */} <ToolbarButton onClick={insertTable} title="Insert Table"> <TableIcon /> </ToolbarButton> <ToolbarButton onClick={insertImage} title="Insert Image"> <ImageIcon /> </ToolbarButton> <div className="te-separator"></div> {/* Undo/Redo */} <ToolbarButton onClick={() => execCommand("undo")} title="Undo (Ctrl+Z)"> <UndoIcon /> </ToolbarButton> <ToolbarButton onClick={() => execCommand("redo")} title="Redo (Ctrl+Y)"> <RedoIcon /> </ToolbarButton> </div> </div> {/* Editor Area */} <div className="te-editor-container"> <div ref={editorRef} contentEditable onInput={handleInput} onPaste={handlePaste} onKeyDown={handleKeyDown} className="te-editor" style={{ height }} suppressContentEditableWarning={true} /> {/* Placeholder */} {!value && <div className="te-placeholder">{placeholder}</div>} </div> {/* Link Dialog */} <LinkDialog /> {/* Hidden file inputs with stronger hiding */} <input ref={fileInputRef} type="file" accept="image/*" onChange={handleImageUpload} className="te-hidden" style={{ display: "none", visibility: "hidden", position: "absolute", left: "-9999px", width: "0", height: "0", opacity: "0", }} /> <input ref={colorInputRef} type="color" onChange={handleCustomTextColor} className="te-hidden" style={{ display: "none", visibility: "hidden", position: "absolute", left: "-9999px", }} /> <input ref={bgColorInputRef} type="color" onChange={handleCustomBgColor} className="te-hidden" style={{ display: "none", visibility: "hidden", position: "absolute", left: "-9999px", }} /> {/* Click outside to close color pickers */} {(showColorPicker || showBgColorPicker) && ( <div className="te-overlay" onClick={() => { setShowColorPicker(false) setShowBgColorPicker(false) }} /> )} </div> ) }