"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) => (
))}
)}
);
}