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
JSX
"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>
)
}