inital working version
This commit is contained in:
184
frontend/src/components/stories/RichTextEditor.tsx
Normal file
184
frontend/src/components/stories/RichTextEditor.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { Textarea } from '../ui/Input';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
interface RichTextEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function RichTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Write your story here...',
|
||||
error
|
||||
}: RichTextEditorProps) {
|
||||
const [viewMode, setViewMode] = useState<'visual' | 'html'>('visual');
|
||||
const [htmlValue, setHtmlValue] = useState(value);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleVisualChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const plainText = e.target.value;
|
||||
// Convert plain text to basic HTML paragraphs
|
||||
const htmlContent = plainText
|
||||
.split('\n\n')
|
||||
.filter(paragraph => paragraph.trim())
|
||||
.map(paragraph => `<p>${paragraph.replace(/\n/g, '<br>')}</p>`)
|
||||
.join('\n');
|
||||
|
||||
onChange(htmlContent);
|
||||
setHtmlValue(htmlContent);
|
||||
};
|
||||
|
||||
const handleHtmlChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const html = e.target.value;
|
||||
setHtmlValue(html);
|
||||
onChange(html);
|
||||
};
|
||||
|
||||
const getPlainText = (html: string): string => {
|
||||
// Simple HTML to plain text conversion
|
||||
return html
|
||||
.replace(/<\/p>/gi, '\n\n')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const formatText = (tag: string) => {
|
||||
if (viewMode === 'visual') {
|
||||
// For visual mode, we'll just show formatting helpers
|
||||
// In a real implementation, you'd want a proper WYSIWYG editor
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = document.querySelector('textarea') as HTMLTextAreaElement;
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const selectedText = htmlValue.substring(start, end);
|
||||
|
||||
if (selectedText) {
|
||||
const beforeText = htmlValue.substring(0, start);
|
||||
const afterText = htmlValue.substring(end);
|
||||
const formattedText = `<${tag}>${selectedText}</${tag}>`;
|
||||
const newValue = beforeText + formattedText + afterText;
|
||||
|
||||
setHtmlValue(newValue);
|
||||
onChange(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between p-2 theme-card border theme-border rounded-t-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setViewMode('visual')}
|
||||
className={viewMode === 'visual' ? 'theme-accent-bg text-white' : ''}
|
||||
>
|
||||
Visual
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setViewMode('html')}
|
||||
className={viewMode === 'html' ? 'theme-accent-bg text-white' : ''}
|
||||
>
|
||||
HTML
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{viewMode === 'html' && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('strong')}
|
||||
title="Bold"
|
||||
>
|
||||
<strong>B</strong>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('em')}
|
||||
title="Italic"
|
||||
>
|
||||
<em>I</em>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('p')}
|
||||
title="Paragraph"
|
||||
>
|
||||
P
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="border theme-border rounded-b-lg overflow-hidden">
|
||||
{viewMode === 'visual' ? (
|
||||
<Textarea
|
||||
value={getPlainText(value)}
|
||||
onChange={handleVisualChange}
|
||||
placeholder={placeholder}
|
||||
rows={12}
|
||||
className="border-0 rounded-none focus:ring-0"
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
value={htmlValue}
|
||||
onChange={handleHtmlChange}
|
||||
placeholder="<p>Write your HTML content here...</p>"
|
||||
rows={12}
|
||||
className="border-0 rounded-none focus:ring-0 font-mono text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview for HTML mode */}
|
||||
{viewMode === 'html' && value && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium theme-header">Preview:</h4>
|
||||
<div
|
||||
ref={previewRef}
|
||||
className="p-4 border theme-border rounded-lg theme-card max-h-40 overflow-y-auto"
|
||||
dangerouslySetInnerHTML={{ __html: value }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="text-xs theme-text">
|
||||
<p>
|
||||
<strong>Visual mode:</strong> Write in plain text, paragraphs will be automatically formatted.
|
||||
</p>
|
||||
<p>
|
||||
<strong>HTML mode:</strong> Write HTML directly for advanced formatting.
|
||||
Allowed tags: p, br, strong, em, ul, ol, li, h1-h6, blockquote.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user