323 lines
11 KiB
TypeScript
323 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useEditor, EditorContent } from '@tiptap/react';
|
|
import { Node, mergeAttributes } from '@tiptap/core';
|
|
import StarterKit from '@tiptap/starter-kit';
|
|
import Underline from '@tiptap/extension-underline';
|
|
import Link from '@tiptap/extension-link';
|
|
import { TextStyle } from '@tiptap/extension-text-style';
|
|
import { Color } from '@tiptap/extension-color';
|
|
import TextAlign from '@tiptap/extension-text-align';
|
|
import Highlight from '@tiptap/extension-highlight';
|
|
import Image from '@tiptap/extension-image';
|
|
import Youtube from '@tiptap/extension-youtube';
|
|
import {
|
|
Bold, Italic, Underline as UnderlineIcon,
|
|
Link as LinkIcon, AlignLeft, AlignCenter,
|
|
AlignRight, AlignJustify, Highlighter, Type,
|
|
List, ListOrdered, Quote, Undo, Redo,
|
|
ImageIcon, Youtube as YoutubeIcon, Palette
|
|
} from 'lucide-react';
|
|
import { useEffect, useState } from 'react';
|
|
import { MediaGridSelector } from './MediaGridSelector';
|
|
|
|
interface RichTextEditorProps {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
label: string;
|
|
}
|
|
|
|
const VideoNode = Node.create({
|
|
name: 'videoNode',
|
|
group: 'block',
|
|
selectable: true,
|
|
draggable: true,
|
|
atom: true,
|
|
|
|
addAttributes() {
|
|
return {
|
|
src: { default: null },
|
|
controls: { default: 'true' },
|
|
}
|
|
},
|
|
|
|
parseHTML() {
|
|
return [{ tag: 'video' }]
|
|
},
|
|
|
|
renderHTML({ HTMLAttributes }) {
|
|
return ['video', mergeAttributes(HTMLAttributes, { class: 'w-full aspect-video rounded-xl shadow-lg my-6', controls: 'true', preload: 'metadata' })]
|
|
},
|
|
});
|
|
|
|
const MenuBar = ({ editor }: { editor: any }) => {
|
|
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false);
|
|
|
|
if (!editor) return null;
|
|
|
|
const setLink = () => {
|
|
const url = window.prompt('URL del enlace:');
|
|
if (url) {
|
|
editor.chain().focus().setLink({ href: url }).run();
|
|
}
|
|
};
|
|
|
|
const addYoutubeVideo = () => {
|
|
const url = window.prompt('URL de YouTube:');
|
|
if (url) {
|
|
editor.commands.setYoutubeVideo({
|
|
src: url,
|
|
width: 640,
|
|
height: 480,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleMediaInsert = (fileId: string, url?: string, tipo?: string) => {
|
|
if (url) {
|
|
if (tipo === 'video') {
|
|
editor.chain().focus().insertContent(`<video src="${url}" controls="true" preload="metadata" class="w-full aspect-video rounded-xl shadow-lg my-6"></video>`).run();
|
|
} else {
|
|
editor.chain().focus().setImage({ src: url }).run();
|
|
}
|
|
}
|
|
setIsMediaModalOpen(false);
|
|
};
|
|
|
|
const setColor = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
editor.chain().focus().setColor(e.target.value).run();
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="flex flex-wrap gap-1 p-2 bg-slate-900 border-b border-slate-800 sticky top-0 z-10 items-center">
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
|
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive('bold') ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
|
|
title="Negrita"
|
|
>
|
|
<Bold size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
|
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive('italic') ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
|
|
title="Cursiva"
|
|
>
|
|
<Italic size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
|
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive('underline') ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
|
|
title="Subrayado"
|
|
>
|
|
<UnderlineIcon size={18} />
|
|
</button>
|
|
|
|
<div className="w-px h-6 bg-slate-800 mx-1 self-center" />
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
|
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive({ textAlign: 'left' }) ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
|
|
>
|
|
<AlignLeft size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
|
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive({ textAlign: 'center' }) ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
|
|
>
|
|
<AlignCenter size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().setTextAlign('right').run()}
|
|
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive({ textAlign: 'right' }) ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
|
|
>
|
|
<AlignRight size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
|
|
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive({ textAlign: 'justify' }) ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
|
|
>
|
|
<AlignJustify size={18} />
|
|
</button>
|
|
|
|
<div className="w-px h-6 bg-slate-800 mx-1 self-center" />
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
|
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive('bulletList') ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
|
|
>
|
|
<List size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
|
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive('orderedList') ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
|
|
>
|
|
<ListOrdered size={18} />
|
|
</button>
|
|
|
|
<div className="w-px h-6 bg-slate-800 mx-1 self-center" />
|
|
|
|
<button
|
|
type="button"
|
|
onClick={setLink}
|
|
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive('link') ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
|
|
>
|
|
<LinkIcon size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().toggleHighlight().run()}
|
|
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive('highlight') ? 'text-yellow-400 bg-slate-800' : 'text-slate-400'}`}
|
|
>
|
|
<Highlighter size={18} />
|
|
</button>
|
|
|
|
<div className="relative group flex items-center">
|
|
<label htmlFor="colorPicker" className="p-2 cursor-pointer text-slate-400 hover:bg-slate-800 rounded transition-colors group-hover:text-cyan-400" title="Color del texto">
|
|
<Palette size={18} />
|
|
</label>
|
|
<input
|
|
id="colorPicker"
|
|
type="color"
|
|
onChange={setColor}
|
|
value={editor.getAttributes('textStyle').color || '#ffffff'}
|
|
className="absolute opacity-0 w-0 h-0"
|
|
/>
|
|
</div>
|
|
|
|
<div className="w-px h-6 bg-slate-800 mx-1 self-center" />
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsMediaModalOpen(true)}
|
|
className="p-2 rounded hover:bg-slate-800 text-slate-400 hover:text-cyan-400 transition-colors"
|
|
title="Insertar Imagen de Biblioteca"
|
|
>
|
|
<ImageIcon size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={addYoutubeVideo}
|
|
className="p-2 rounded hover:bg-slate-800 text-slate-400 hover:text-red-400 transition-colors"
|
|
title="Insertar Video YouTube"
|
|
>
|
|
<YoutubeIcon size={18} />
|
|
</button>
|
|
|
|
<div className="flex-grow" />
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().undo().run()}
|
|
className="p-2 text-slate-500 hover:text-white transition-colors"
|
|
>
|
|
<Undo size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().redo().run()}
|
|
className="p-2 text-slate-500 hover:text-white transition-colors"
|
|
>
|
|
<Redo size={18} />
|
|
</button>
|
|
</div>
|
|
<MediaGridSelector
|
|
mode="modal"
|
|
isOpen={isMediaModalOpen}
|
|
onClose={() => setIsMediaModalOpen(false)}
|
|
onChange={handleMediaInsert}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export const RichTextEditor = ({ value, onChange, label }: RichTextEditorProps) => {
|
|
const editor = useEditor({
|
|
extensions: [
|
|
StarterKit,
|
|
Underline,
|
|
Link.configure({
|
|
openOnClick: false,
|
|
HTMLAttributes: {
|
|
class: 'text-cyan-400 underline decoration-cyan-400/50 hover:text-cyan-300 transition-colors',
|
|
},
|
|
}),
|
|
TextStyle,
|
|
Color,
|
|
Image.configure({
|
|
inline: true,
|
|
allowBase64: true,
|
|
HTMLAttributes: {
|
|
class: 'rounded-xl shadow-lg max-w-full h-auto object-cover my-6'
|
|
}
|
|
}),
|
|
VideoNode,
|
|
Youtube.configure({
|
|
controls: true,
|
|
HTMLAttributes: {
|
|
class: 'w-full aspect-video rounded-xl shadow-lg my-6'
|
|
}
|
|
}),
|
|
TextAlign.configure({
|
|
types: ['heading', 'paragraph'],
|
|
alignments: ['left', 'center', 'right', 'justify'],
|
|
}),
|
|
Highlight.configure({ multicolor: true }),
|
|
],
|
|
content: value,
|
|
onUpdate: ({ editor }) => {
|
|
onChange(editor.getHTML());
|
|
},
|
|
editorProps: {
|
|
attributes: {
|
|
class: 'prose prose-invert max-w-none focus:outline-none min-h-[300px] p-6 text-slate-200 leading-relaxed custom-scrollbar whitespace-pre-wrap break-words prose-p:empty:before:content-["\\00a0"]',
|
|
},
|
|
},
|
|
});
|
|
|
|
const [mounted, setMounted] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
// Keep content synced if value changes externally
|
|
useEffect(() => {
|
|
if (editor && value !== editor.getHTML()) {
|
|
editor.commands.setContent(value);
|
|
}
|
|
}, [value, editor]);
|
|
|
|
if (!mounted) {
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
<label className="text-sm font-bold tracking-widest text-slate-300 uppercase">{label}</label>
|
|
<div className="h-[400px] bg-slate-900 animate-pulse rounded-2xl border border-slate-800" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
<label className="text-sm font-bold tracking-widest text-slate-300 uppercase">{label}</label>
|
|
<div className="bg-slate-950 border border-slate-800 rounded-2xl overflow-hidden focus-within:border-cyan-500/50 transition-all shadow-xl flex flex-col max-h-[600px]">
|
|
<MenuBar editor={editor} />
|
|
<div className="overflow-y-auto custom-scrollbar flex-1">
|
|
<EditorContent editor={editor} />
|
|
</div>
|
|
</div>
|
|
{/* Hidden input for form submission if needed, though we use controlled value */}
|
|
<input type="hidden" name={label.toLowerCase().replace(/ /g, '_')} value={value} />
|
|
</div>
|
|
);
|
|
};
|