'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { LexicalComposer } from '@lexical/react/LexicalComposer'; import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; import { ContentEditable } from '@lexical/react/LexicalContentEditable'; import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'; import { ListPlugin } from '@lexical/react/LexicalListPlugin'; import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'; import { HeadingNode, QuoteNode, $createHeadingNode } from '@lexical/rich-text'; import { ListItemNode, ListNode, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND } from '@lexical/list'; import { LinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'; import { $getRoot, $createParagraphNode, EditorState, LexicalEditor as LexicalEditorInstance, FORMAT_TEXT_COMMAND, $isElementNode, ElementNode, $getSelection, $isRangeSelection, $createParagraphNode as createParagraph, } from 'lexical'; import { $generateHtmlFromNodes, $generateNodesFromDOM } from '@lexical/html'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; const EMPTY_EDITOR_STATE = JSON.stringify({ root: { children: [ { children: [], direction: 'ltr', format: '', indent: 0, type: 'paragraph', version: 1, }, ], direction: 'ltr', format: '', indent: 0, type: 'root', version: 1, }, }); const editorTheme = { paragraph: 'lex-paragraph', heading: { h1: 'lex-h1', h2: 'lex-h2', h3: 'lex-h3', h4: 'lex-h4', }, list: { ul: 'lex-ul', ol: 'lex-ol', listitem: 'lex-li', }, link: 'lex-link', text: { bold: 'lex-bold', italic: 'lex-italic', underline: 'lex-underline', }, }; function makeConfig(disabled: boolean) { return { namespace: 'MDVCmsEditor', nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode], editorState: EMPTY_EDITOR_STATE, editable: !disabled, onError(error: Error) { throw error; }, theme: editorTheme, }; } type FormatState = { bold: boolean; italic: boolean; underline: boolean }; function readFormatFromDom(): FormatState { if (typeof window === 'undefined') return { bold: false, italic: false, underline: false }; const domSel = window.getSelection?.(); let bold = false; let italic = false; let underline = false; if (domSel && domSel.rangeCount > 0) { let node: Node | null = domSel.getRangeAt(0).startContainer; while (node && node !== document.body) { if (node instanceof HTMLElement) { const fw = node.style?.fontWeight; if (node.tagName === 'B' || node.tagName === 'STRONG' || fw === 'bold' || fw === '700') bold = true; if (node.tagName === 'I' || node.tagName === 'EM') italic = true; if (node.tagName === 'U' || node.style?.textDecoration?.includes('underline')) underline = true; } node = node.parentNode; } } return { bold, italic, underline }; } function ToolbarButton({ active, disabled, onClick, title, children, }: { active?: boolean; disabled?: boolean; onClick: (e: React.MouseEvent) => void; title: string; children: React.ReactNode; }) { return ( e.preventDefault()} onClick={onClick} title={title} aria-label={title} className={`px-2.5 py-1.5 text-sm font-medium rounded-md transition-colors ${ active ? 'bg-[#2D6A4F] text-white' : 'text-gray-700 hover:bg-gray-100' } ${disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`} > {children} ); } function Toolbar({ disabled }: { disabled?: boolean }) { const [editor] = useLexicalComposerContext(); const [active, setActive] = useState({ bold: false, italic: false, underline: false }); useEffect(() => { return editor.registerUpdateListener(() => { // Defer to next microtask so React state updates are batched inside the editor's update loop. queueMicrotask(() => setActive(readFormatFromDom())); }); }, [editor]); const setBlockType = (type: 'paragraph' | 'h2' | 'h3') => { editor.update(() => { const selection = $getSelection(); if (!$isRangeSelection(selection)) return; const anchor = selection.anchor.getNode(); let target: ElementNode | null = anchor.getParent(); while (target && target.getType() !== 'paragraph' && target.getType() !== 'heading') { target = target.getParent(); } if (!target) return; const currentType = target.getType(); if (type === 'paragraph') { if (currentType === 'heading') { const para = createParagraph(); para.append(...target.getChildren()); target.replace(para); } } else if (currentType === 'paragraph') { const heading = $createHeadingNode(type); heading.append(...target.getChildren()); target.replace(heading); } else if (currentType === 'heading') { // Re-create the heading so the new tag is honoured (ElementNode type is immutable). const heading = $createHeadingNode(type); heading.append(...target.getChildren()); target.replace(heading); } }); }; return ( editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')} title="Negrita (Ctrl+B)" active={active.bold} > B editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')} title="Cursiva (Ctrl+I)" active={active.italic} > I editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline')} title="Subrayado (Ctrl+U)" active={active.underline} > U setBlockType('h2')} title="Encabezado 2" > H2 setBlockType('h3')} title="Encabezado 3" > H3 setBlockType('paragraph')} title="Párrafo normal" > ¶ editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined)} title="Lista con viñetas" > • editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined)} title="Lista numerada" > 1. { if (typeof window === 'undefined') return; const url = window.prompt('URL del enlace (vacío para quitar):', 'https://'); if (url === null) return; editor.dispatchCommand(TOGGLE_LINK_COMMAND, url === '' ? null : url); }} title="Insertar / quitar enlace" > 🔗 ); } function InitialHtmlPlugin({ initialHtml }: { initialHtml: string }) { const [editor] = useLexicalComposerContext(); const applied = useRef(false); useEffect(() => { if (applied.current) return; if (!initialHtml || !initialHtml.trim()) return; applied.current = true; editor.update(() => { const parser = new DOMParser(); const domDoc = parser.parseFromString(`${initialHtml}`, 'text/html'); const nodes = $generateNodesFromDOM(editor, domDoc.body); const root = $getRoot(); root.clear(); const ensured: ElementNode[] = []; nodes.forEach((node) => { if ($isElementNode(node)) { ensured.push(node); } else { const p = $createParagraphNode(); p.append(node as never); ensured.push(p); } }); if (ensured.length === 0) { ensured.push($createParagraphNode()); } ensured.forEach((n) => root.append(n)); }); }, [editor, initialHtml]); return null; } export interface LexicalEditorProps { value: string; onChange: (html: string) => void; placeholder?: string; disabled?: boolean; className?: string; } export default function LexicalEditor({ value, onChange, placeholder = 'Escribe el contenido…', disabled, className = '', }: LexicalEditorProps) { const handleChange = useCallback( (editorState: EditorState, _editor: LexicalEditorInstance) => { editorState.read(() => { const html = $generateHtmlFromNodes(_editor, null); onChange(html); }); }, [onChange], ); const config = useMemo(() => makeConfig(!!disabled), [disabled]); return ( } placeholder={ {placeholder} } ErrorBoundary={LexicalErrorBoundary} /> ); }