"use client"
import React, { useCallback, useState, useEffect } from 'react'
import { motion } from 'framer-motion'
import { Button } from './button'
import { Separator } from './separator'
import { Toggle } from './toggle'
import { Popover, PopoverContent, PopoverTrigger } from './popover'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip'
import { cn } from '../../lib/utils'
import {
Bold,
Italic,
Underline,
Strikethrough,
AlignLeft,
AlignCenter,
AlignRight,
AlignJustify,
List,
ListOrdered,
Quote,
Link,
Image as ImageIcon,
Code,
Type,
Palette,
Undo,
Redo,
Eye,
Edit
} from 'lucide-react'
export interface SimpleEditorProps {
value?: string
onChange?: (value: string) => void
placeholder?: string
disabled?: boolean
className?: string
minHeight?: number
maxHeight?: number
showToolbar?: boolean
showPreview?: boolean
}
interface ToolbarButtonProps {
icon: React.ReactNode
tooltip: string
active?: boolean
onClick: () => void
disabled?: boolean
}
const ToolbarButton = ({ icon, tooltip, active, onClick, disabled }: ToolbarButtonProps) => (
onClick()}
disabled={disabled}
className="h-8 w-8 p-0"
>
{icon}
{tooltip}
)
const ColorPicker = ({ onColorSelect }: { onColorSelect: (color: string) => void }) => {
const colors = [
'#000000', '#374151', '#6B7280', '#9CA3AF',
'#EF4444', '#F59E0B', '#EAB308', '#22C55E',
'#3B82F6', '#6366F1', '#8B5CF6', '#EC4899'
]
return (
{colors.map(color => (
)
}
export const SimpleEditor = React.forwardRef(
({
value = '',
onChange,
placeholder = 'Start writing...',
disabled = false,
className,
minHeight = 200,
maxHeight = 500,
showToolbar = true,
showPreview = true,
...props
}, ref) => {
const [content, setContent] = useState(value)
const [isPreview, setIsPreview] = useState(false)
const [sourceContent, setSourceContent] = useState(value)
const [selection, setSelection] = useState<{ start: number; end: number } | null>(null)
const editorRef = React.useRef(null)
const sourceRef = React.useRef(null)
// Format state tracking
const [formatState, setFormatState] = useState({
bold: false,
italic: false,
underline: false,
strikethrough: false,
alignLeft: true,
alignCenter: false,
alignRight: false,
alignJustify: false,
orderedList: false,
unorderedList: false,
quote: false,
code: false
})
const updateContent = useCallback((newContent: string) => {
setContent(newContent)
onChange?.(newContent)
}, [onChange])
const saveSelection = useCallback(() => {
const selection = window.getSelection()
if (selection && selection.rangeCount > 0) {
return selection.getRangeAt(0)
}
return null
}, [])
const restoreSelection = useCallback((range: Range | null) => {
if (!range) return
const selection = window.getSelection()
if (selection) {
selection.removeAllRanges()
selection.addRange(range)
}
}, [])
const executeCommand = useCallback((command: string, value?: string) => {
if (disabled) return
// Focus the editor first
if (editorRef.current) {
editorRef.current.focus()
}
// Save current selection before executing command
const savedRange = saveSelection()
// Execute the command
const result = document.execCommand(command, false, value)
if (!result) {
console.warn(`Command '${command}' failed to execute`)
}
// Update content state to trigger re-render
if (editorRef.current) {
const newContent = editorRef.current.innerHTML
updateContent(newContent)
}
// Update format state
setFormatState({
bold: document.queryCommandState('bold'),
italic: document.queryCommandState('italic'),
underline: document.queryCommandState('underline'),
strikethrough: document.queryCommandState('strikeThrough'),
alignLeft: document.queryCommandState('justifyLeft'),
alignCenter: document.queryCommandState('justifyCenter'),
alignRight: document.queryCommandState('justifyRight'),
alignJustify: document.queryCommandState('justifyFull'),
orderedList: document.queryCommandState('insertOrderedList'),
unorderedList: document.queryCommandState('insertUnorderedList'),
quote: document.queryCommandState('formatBlock'),
code: document.queryCommandState('formatBlock')
})
// Keep focus on editor
if (editorRef.current) {
editorRef.current.focus()
}
}, [disabled, saveSelection, updateContent])
const handleInput = useCallback((e: React.FormEvent) => {
const newContent = e.currentTarget.innerHTML
updateContent(newContent)
}, [updateContent])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
// Handle keyboard shortcuts
if (e.ctrlKey || e.metaKey) {
switch (e.key) {
case 'b':
e.preventDefault()
executeCommand('bold')
break
case 'i':
e.preventDefault()
executeCommand('italic')
break
case 'u':
e.preventDefault()
executeCommand('underline')
break
case 'z':
e.preventDefault()
if (e.shiftKey) {
executeCommand('redo')
} else {
executeCommand('undo')
}
break
}
}
// Handle Tab for indentation
if (e.key === 'Tab') {
e.preventDefault()
executeCommand('insertHTML', ' ')
}
}, [executeCommand])
const insertLink = useCallback(() => {
const url = prompt('Enter URL:')
if (url) {
executeCommand('createLink', url)
}
}, [executeCommand])
const insertImage = useCallback(() => {
const url = prompt('Enter image URL:')
if (url) {
executeCommand('insertImage', url)
}
}, [executeCommand])
const formatHeading = useCallback((level: string) => {
executeCommand('formatBlock', ``)
}, [executeCommand])
const setTextColor = useCallback((color: string) => {
// First try the modern way
const selection = window.getSelection()
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
const span = document.createElement('span')
span.style.color = color
try {
range.surroundContents(span)
} catch (e) {
// If surroundContents fails, use execCommand
executeCommand('foreColor', color)
}
// Update content
if (editorRef.current) {
const newContent = editorRef.current.innerHTML
updateContent(newContent)
}
}
}, [executeCommand, updateContent])
// Convert HTML to plain text for preview
const getPlainText = (html: string) => {
if (typeof window === 'undefined') {
// Server-side: simple regex-based HTML stripping
return html
.replace(/<[^>]*>/g, '') // Remove HTML tags
.replace(/ /g, ' ') // Replace with space
.replace(/&/g, '&') // Replace & with &
.replace(/</g, '<') // Replace < with <
.replace(/>/g, '>') // Replace > with >
.replace(/"/g, '"') // Replace " with "
.replace(/'/g, "'") // Replace ' with '
}
// Client-side: use DOM for accurate conversion
const div = document.createElement('div')
div.innerHTML = html
return div.textContent || div.innerText || ''
}
React.useEffect(() => {
if (editorRef.current && content && content !== editorRef.current.innerHTML) {
editorRef.current.innerHTML = content
}
}, [content])
// Sync source content when value prop changes
React.useEffect(() => {
if (value !== undefined && value !== content) {
setContent(value)
setSourceContent(value)
}
}, [value, content])
const toolbar = showToolbar && (
{/* Text Formatting */}
{!isPreview && (
<>
}
tooltip="Bold (Ctrl+B)"
active={formatState.bold}
onClick={() => executeCommand('bold')}
disabled={disabled}
/>
}
tooltip="Italic (Ctrl+I)"
active={formatState.italic}
onClick={() => executeCommand('italic')}
disabled={disabled}
/>
}
tooltip="Underline (Ctrl+U)"
active={formatState.underline}
onClick={() => executeCommand('underline')}
disabled={disabled}
/>
}
tooltip="Strikethrough"
active={formatState.strikethrough}
onClick={() => executeCommand('strikeThrough')}
disabled={disabled}
/>
{/* Font Size / Heading */}
{/* Alignment */}
}
tooltip="Align Left"
active={formatState.alignLeft}
onClick={() => executeCommand('justifyLeft')}
disabled={disabled}
/>
}
tooltip="Align Center"
active={formatState.alignCenter}
onClick={() => executeCommand('justifyCenter')}
disabled={disabled}
/>
}
tooltip="Align Right"
active={formatState.alignRight}
onClick={() => executeCommand('justifyRight')}
disabled={disabled}
/>
}
tooltip="Justify"
active={formatState.alignJustify}
onClick={() => executeCommand('justifyFull')}
disabled={disabled}
/>
{/* Lists */}
}
tooltip="Bullet List"
active={formatState.unorderedList}
onClick={() => executeCommand('insertUnorderedList')}
disabled={disabled}
/>
}
tooltip="Numbered List"
active={formatState.orderedList}
onClick={() => executeCommand('insertOrderedList')}
disabled={disabled}
/>
}
tooltip="Quote"
active={formatState.quote}
onClick={() => executeCommand('formatBlock', 'blockquote')}
disabled={disabled}
/>
}
tooltip="Code Block"
active={formatState.code}
onClick={() => executeCommand('formatBlock', 'pre')}
disabled={disabled}
/>
{/* Indentation */}
←}
tooltip="Decrease Indent"
onClick={() => executeCommand('outdent')}
disabled={disabled}
/>
→}
tooltip="Increase Indent"
onClick={() => executeCommand('indent')}
disabled={disabled}
/>
{/* Insert */}
}
tooltip="Insert Link"
onClick={insertLink}
disabled={disabled}
/>
}
tooltip="Insert Image"
onClick={insertImage}
disabled={disabled}
/>
{/* Color */}
{/* Undo/Redo */}
}
tooltip="Undo (Ctrl+Z)"
onClick={() => executeCommand('undo')}
disabled={disabled}
/>
}
tooltip="Redo (Ctrl+Shift+Z)"
onClick={() => executeCommand('redo')}
disabled={disabled}
/>
>
)}
{/* Preview Toggle */}
{showPreview && (
<>
{!isPreview && }
: }
tooltip={isPreview ? "Edit Mode" : "View Source Code"}
active={isPreview}
onClick={() => {
if (isPreview) {
// Apply source changes to content when switching back to edit mode
updateContent(sourceContent)
} else {
// Update source content when switching to preview mode
setSourceContent(content)
}
setIsPreview(!isPreview)
}}
disabled={disabled}
/>
>
)}
)
return (
{toolbar}
{isPreview ? (
) : (
)}
{/* Placeholder */}
{!content && !isPreview && (
{placeholder}
)}
{/* Character Count */}
)
}
)
SimpleEditor.displayName = "SimpleEditor"
// Character count component to avoid hydration issues
const CharacterCount = ({ content }: { content: string }) => {
const [count, setCount] = useState(0)
useEffect(() => {
const getPlainText = (html: string) => {
const div = document.createElement('div')
div.innerHTML = html
return div.textContent || div.innerText || ''
}
setCount(getPlainText(content).length)
}, [content])
return <>{count} characters>
}