Files
mercadodevida/project/apps/admin/src/features/cms/components/LexicalEditor.tsx
2026-08-19 17:02:14 +02:00

360 lines
11 KiB
TypeScript

'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 (
<button
type="button"
disabled={disabled}
onMouseDown={(e) => 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}
</button>
);
}
function Toolbar({ disabled }: { disabled?: boolean }) {
const [editor] = useLexicalComposerContext();
const [active, setActive] = useState<FormatState>({ 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 (
<div className="flex flex-wrap gap-1 border-b border-gray-200 p-2 bg-gray-50 rounded-t-xl">
<ToolbarButton
disabled={disabled}
onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')}
title="Negrita (Ctrl+B)"
active={active.bold}
>
<span className="font-bold">B</span>
</ToolbarButton>
<ToolbarButton
disabled={disabled}
onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')}
title="Cursiva (Ctrl+I)"
active={active.italic}
>
<span className="italic">I</span>
</ToolbarButton>
<ToolbarButton
disabled={disabled}
onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline')}
title="Subrayado (Ctrl+U)"
active={active.underline}
>
<span className="underline">U</span>
</ToolbarButton>
<div className="w-px bg-gray-300 mx-1 self-stretch" />
<ToolbarButton
disabled={disabled}
onClick={() => setBlockType('h2')}
title="Encabezado 2"
>
<span className="text-xs">H2</span>
</ToolbarButton>
<ToolbarButton
disabled={disabled}
onClick={() => setBlockType('h3')}
title="Encabezado 3"
>
<span className="text-xs">H3</span>
</ToolbarButton>
<ToolbarButton
disabled={disabled}
onClick={() => setBlockType('paragraph')}
title="Párrafo normal"
>
<span className="text-xs"></span>
</ToolbarButton>
<div className="w-px bg-gray-300 mx-1 self-stretch" />
<ToolbarButton
disabled={disabled}
onClick={() => editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined)}
title="Lista con viñetas"
>
</ToolbarButton>
<ToolbarButton
disabled={disabled}
onClick={() => editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined)}
title="Lista numerada"
>
1.
</ToolbarButton>
<ToolbarButton
disabled={disabled}
onClick={() => {
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"
>
🔗
</ToolbarButton>
</div>
);
}
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(`<div>${initialHtml}</div>`, '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 (
<div className={`border border-gray-300 rounded-xl overflow-hidden bg-white ${className}`}>
<LexicalComposer initialConfig={config}>
<Toolbar disabled={disabled} />
<div className="relative">
<RichTextPlugin
contentEditable={
<ContentEditable
className="min-h-[220px] px-4 py-3 text-sm text-gray-800 focus:outline-none prose-sm max-w-none"
aria-label="Contenido CMS"
/>
}
placeholder={
<div className="absolute top-3 left-4 text-sm text-gray-400 pointer-events-none select-none">
{placeholder}
</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
</div>
<HistoryPlugin />
<ListPlugin />
<LinkPlugin />
<InitialHtmlPlugin initialHtml={value} />
<OnChangePlugin onChange={handleChange} ignoreSelectionChange />
</LexicalComposer>
<style jsx global>{`
.lex-paragraph { margin: 0 0 0.5rem 0; }
.lex-paragraph:last-child { margin-bottom: 0; }
.lex-h2 { font-size: 1.5rem; font-weight: 700; margin: 1rem 0 0.5rem; color: #111827; }
.lex-h3 { font-size: 1.25rem; font-weight: 600; margin: 0.75rem 0 0.5rem; color: #111827; }
.lex-h4 { font-size: 1.05rem; font-weight: 600; margin: 0.5rem 0 0.25rem; color: #111827; }
.lex-ul { list-style: disc; padding-left: 1.5rem; margin: 0.25rem 0; }
.lex-ol { list-style: decimal; padding-left: 1.5rem; margin: 0.25rem 0; }
.lex-li { margin: 0.125rem 0; }
.lex-link { color: #2D6A4F; text-decoration: underline; }
.lex-bold { font-weight: 700; }
.lex-italic { font-style: italic; }
.lex-underline { text-decoration: underline; }
`}</style>
</div>
);
}