285 lines
8.1 KiB
TypeScript
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; |