"use client"; import Link from "@tiptap/extension-link"; import Placeholder from "@tiptap/extension-placeholder"; import { Table } from "@tiptap/extension-table"; import TableCell from "@tiptap/extension-table-cell"; import TableHeader from "@tiptap/extension-table-header"; import TableRow from "@tiptap/extension-table-row"; import TextAlign from "@tiptap/extension-text-align"; import Underline from "@tiptap/extension-underline"; import { EditorContent, useEditor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { useEffect, useMemo, useRef, useState } from "react"; type EditorMode = "basic" | "news"; type RichTextEditorProps = { label: string; value: string; placeholder?: string; minHeightClassName?: string; mode?: EditorMode; onChange: (next: string) => void; }; type BlockAction = { id: string; label: string; run: () => void; }; type SlashMenuState = { open: boolean; top: number; left: number; slashPos: number | null; }; function normalizeHtml(html: string) { const trimmed = html.trim(); if (!trimmed) return ""; if (trimmed === "
" || trimmed === "


") return ""; return trimmed; } function buttonClass(active = false) { return `px-2 py-1 rounded text-xs transition-colors ${ active ? "bg-[#ECFDF5] text-[#0F766E]" : "text-gray-700 hover:bg-gray-200" }`; } export function RichTextEditor({ label, value, placeholder, minHeightClassName = "min-h-[180px]", mode = "basic", onChange, }: RichTextEditorProps) { const containerRef = useRef(null); const [blockMenuOpen, setBlockMenuOpen] = useState(false); const [slashMenu, setSlashMenu] = useState({ open: false, top: 0, left: 0, slashPos: null, }); const editor = useEditor({ extensions: [ StarterKit.configure({ heading: { levels: [2, 3] }, }), Underline, Link.configure({ openOnClick: false, HTMLAttributes: { rel: "noopener noreferrer", target: "_blank", }, }), TextAlign.configure({ types: ["heading", "paragraph"] }), Placeholder.configure({ placeholder: placeholder ?? "Inhalt schreiben...", }), Table.configure({ resizable: true, HTMLAttributes: { class: "nf-editor-table" }, }), TableRow, TableHeader, TableCell, ], content: value || "", immediatelyRender: false, onUpdate: ({ editor: ed }) => { onChange(normalizeHtml(ed.getHTML())); }, }); const blockActions = useMemo(() => { if (!editor) return []; return [ { id: "paragraph", label: "Absatz", run: () => editor.chain().focus().setParagraph().run() }, { id: "h2", label: "Überschrift H2", run: () => editor.chain().focus().toggleHeading({ level: 2 }).run() }, { id: "h3", label: "Überschrift H3", run: () => editor.chain().focus().toggleHeading({ level: 3 }).run() }, { id: "bullet", label: "Aufzählung", run: () => editor.chain().focus().toggleBulletList().run() }, { id: "ordered", label: "Nummerierte Liste", run: () => editor.chain().focus().toggleOrderedList().run() }, { id: "quote", label: "Zitat", run: () => editor.chain().focus().toggleBlockquote().run() }, { id: "table", label: "Tabelle (3x3)", run: () => editor .chain() .focus() .insertTable({ rows: 3, cols: 3, withHeaderRow: true }) .run(), }, ]; }, [editor]); useEffect(() => { if (!editor) return; const normalized = value || ""; if (editor.getHTML() !== normalized) { editor.commands.setContent(normalized, { emitUpdate: false }); } }, [editor, value]); useEffect(() => { if (!editor || mode !== "news") return; const onKeyDown = (event: KeyboardEvent) => { if (!editor.isFocused) return; if (event.key === "Escape") { setSlashMenu((prev) => ({ ...prev, open: false })); return; } if (event.key !== "/") return; setTimeout(() => { if (!containerRef.current) return; const pos = editor.state.selection.from; const coords = editor.view.coordsAtPos(pos); const containerRect = containerRef.current.getBoundingClientRect(); setSlashMenu({ open: true, top: coords.bottom - containerRect.top + 6, left: Math.max(8, coords.left - containerRect.left), slashPos: pos, }); }, 0); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [editor, mode]); useEffect(() => { function onClickOutside(event: MouseEvent) { if (!containerRef.current) return; if (!containerRef.current.contains(event.target as Node)) { setBlockMenuOpen(false); setSlashMenu((prev) => ({ ...prev, open: false })); } } window.addEventListener("mousedown", onClickOutside); return () => window.removeEventListener("mousedown", onClickOutside); }, []); function applyBlockAction(action: BlockAction) { if (!editor) return; if (slashMenu.open && slashMenu.slashPos) { editor .chain() .focus() .deleteRange({ from: slashMenu.slashPos - 1, to: slashMenu.slashPos }) .run(); } action.run(); setBlockMenuOpen(false); setSlashMenu((prev) => ({ ...prev, open: false, slashPos: null })); } function handleLink() { if (!editor) return; const url = window.prompt("Link-URL eingeben (https://...)"); if (url === null) return; const trimmedUrl = url.trim(); if (!trimmedUrl) { editor.chain().focus().extendMarkRange("link").unsetLink().run(); return; } editor.chain().focus().extendMarkRange("link").setLink({ href: trimmedUrl }).run(); } if (!editor) { return null; } const showNewsBlockFeatures = mode === "news"; return (
{showNewsBlockFeatures && (
{blockMenuOpen && (
{blockActions.map((action) => ( ))}
)}
)} {showNewsBlockFeatures && ( <> )}
{showNewsBlockFeatures && slashMenu.open && (

Slash-Menü

{blockActions.map((action) => ( ))}
)}
); }