360 lines
11 KiB
TypeScript
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>
|
|
);
|
|
} |