lfg9-forums/frontend/src/components/RichTextEditor.tsx
Developer 097d5c4109 init
2025-09-02 14:05:42 -05:00

285 lines
8.1 KiB
TypeScript

import React, { useCallback } from 'react';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Image from '@tiptap/extension-image';
import Link from '@tiptap/extension-link';
import Highlight from '@tiptap/extension-highlight';
import TextStyle from '@tiptap/extension-text-style';
import Color from '@tiptap/extension-color';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import { lowlight } from 'lowlight';
import {
Bold,
Italic,
Underline,
Strikethrough,
Code,
Quote,
List,
ListOrdered,
Image as ImageIcon,
Link as LinkIcon,
Undo,
Redo,
} from 'lucide-react';
interface RichTextEditorProps {
content?: any;
onChange?: (content: any) => void;
placeholder?: string;
editable?: boolean;
className?: string;
}
const MenuButton: React.FC<{
onClick: () => void;
isActive?: boolean;
disabled?: boolean;
children: React.ReactNode;
title?: string;
}> = ({ onClick, isActive, disabled, children, title }) => (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
className={`
p-2 rounded-md text-sm font-medium transition-all duration-200
${isActive
? 'bg-lfg-lavender text-lfg-black'
: 'text-lfg-lavender hover:bg-lfg-oxford hover:text-white'
}
${disabled
? 'opacity-50 cursor-not-allowed'
: 'hover:scale-105'
}
focus:outline-none focus:ring-2 focus:ring-lfg-lavender focus:ring-opacity-50
`}
>
{children}
</button>
);
const RichTextEditor: React.FC<RichTextEditorProps> = ({
content,
onChange,
placeholder = 'Start writing...',
editable = true,
className = '',
}) => {
const editor = useEditor({
extensions: [
StarterKit.configure({
codeBlock: false,
}),
Image.configure({
HTMLAttributes: {
class: 'max-w-full h-auto rounded-lg',
},
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: 'text-lfg-lavender hover:text-white underline',
},
}),
Highlight.configure({
HTMLAttributes: {
class: 'bg-lfg-lavender text-lfg-black px-1 rounded',
},
}),
TextStyle,
Color,
CodeBlockLowlight.configure({
lowlight,
HTMLAttributes: {
class: 'bg-lfg-black text-lfg-lavender p-4 rounded-lg overflow-x-auto',
},
}),
],
content,
editable,
onUpdate: ({ editor }) => {
onChange?.(editor.getJSON());
},
});
const addImage = useCallback(() => {
const url = window.prompt('Enter image URL:');
if (url && editor) {
editor.chain().focus().setImage({ src: url }).run();
}
}, [editor]);
const setLink = useCallback(() => {
const previousUrl = editor?.getAttributes('link').href;
const url = window.prompt('Enter URL:', previousUrl);
if (url === null) {
return;
}
if (url === '') {
editor?.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
editor?.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
}, [editor]);
if (!editor) {
return <div className="animate-pulse bg-lfg-oxford h-32 rounded-lg"></div>;
}
return (
<div className={`border border-lfg-oxford rounded-lg bg-lfg-rich-black ${className}`}>
{editable && (
<div className="border-b border-lfg-oxford p-2 flex flex-wrap gap-1">
<MenuButton
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
title="Bold"
>
<Bold size={16} />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')}
title="Italic"
>
<Italic size={16} />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleStrike().run()}
isActive={editor.isActive('strike')}
title="Strikethrough"
>
<Strikethrough size={16} />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleCode().run()}
isActive={editor.isActive('code')}
title="Code"
>
<Code size={16} />
</MenuButton>
<div className="w-px bg-lfg-charcoal mx-1"></div>
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
isActive={editor.isActive('heading', { level: 1 })}
title="Heading 1"
>
H1
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
isActive={editor.isActive('heading', { level: 2 })}
title="Heading 2"
>
H2
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
isActive={editor.isActive('heading', { level: 3 })}
title="Heading 3"
>
H3
</MenuButton>
<div className="w-px bg-lfg-charcoal mx-1"></div>
<MenuButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
isActive={editor.isActive('bulletList')}
title="Bullet List"
>
<List size={16} />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
isActive={editor.isActive('orderedList')}
title="Numbered List"
>
<ListOrdered size={16} />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
isActive={editor.isActive('blockquote')}
title="Quote"
>
<Quote size={16} />
</MenuButton>
<div className="w-px bg-lfg-charcoal mx-1"></div>
<MenuButton
onClick={setLink}
isActive={editor.isActive('link')}
title="Add Link"
>
<LinkIcon size={16} />
</MenuButton>
<MenuButton
onClick={addImage}
title="Add Image"
>
<ImageIcon size={16} />
</MenuButton>
<div className="w-px bg-lfg-charcoal mx-1"></div>
<MenuButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().chain().focus().undo().run()}
title="Undo"
>
<Undo size={16} />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().chain().focus().redo().run()}
title="Redo"
>
<Redo size={16} />
</MenuButton>
</div>
)}
<EditorContent
editor={editor}
className={`
prose prose-invert max-w-none p-4
prose-headings:text-lfg-lavender prose-p:text-lfg-lavender
prose-strong:text-lfg-lavender prose-code:text-lfg-lavender
prose-blockquote:border-lfg-lavender prose-blockquote:text-lfg-charcoal
prose-li:text-lfg-lavender prose-a:text-lfg-lavender
focus-within:ring-2 focus-within:ring-lfg-lavender focus-within:ring-opacity-50
`}
/>
{editable && (
<div className="px-4 py-2 text-xs text-lfg-charcoal border-t border-lfg-oxford">
<p>
Tips: Use <kbd className="bg-lfg-oxford px-1 rounded text-lfg-lavender">**bold**</kbd>,{' '}
<kbd className="bg-lfg-oxford px-1 rounded text-lfg-lavender">*italic*</kbd>,{' '}
<kbd className="bg-lfg-oxford px-1 rounded text-lfg-lavender">`code`</kbd>,{' '}
or paste images directly
</p>
</div>
)}
</div>
);
};
export default RichTextEditor;