feat(F-062): completed feature
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { cmsApi } from '@/lib/api-client';
|
||||
import LexicalEditor from '@/features/cms/components/LexicalEditor';
|
||||
|
||||
interface Page { id: string; slug: string; title: string; body: string; status: string; createdAt: string; updatedAt: string; }
|
||||
|
||||
@@ -92,7 +93,11 @@ export default function CmsPage() {
|
||||
))}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Contenido *</label>
|
||||
<textarea value={form.body} onChange={e => setForm({...form,body:e.target.value})} rows={6} className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none resize-none font-mono" />
|
||||
<LexicalEditor
|
||||
value={form.body}
|
||||
onChange={(html) => setForm((f) => ({ ...f, body: html }))}
|
||||
placeholder="Escribe el contenido de la página…"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={handleSave} disabled={saving || !form.slug || !form.title || !form.body} className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl">{saving ? 'Guardando...' : (editing ? 'Guardar cambios' : 'Crear')}</button>
|
||||
|
||||
360
project/apps/admin/src/features/cms/components/LexicalEditor.tsx
Normal file
360
project/apps/admin/src/features/cms/components/LexicalEditor.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user