hellth-hub/apps/admin/app/_components/rich-text-editor.tsx

460 lines
15 KiB
TypeScript
Raw Permalink Normal View History

"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 === "<br>" || trimmed === "<p><br></p>") 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<HTMLDivElement>(null);
const [blockMenuOpen, setBlockMenuOpen] = useState(false);
const [slashMenu, setSlashMenu] = useState<SlashMenuState>({
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<BlockAction[]>(() => {
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 (
<div ref={containerRef} className="relative">
<label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
<div className="border border-gray-200 rounded-md overflow-hidden bg-white">
<div className="flex flex-wrap items-center gap-1 border-b border-gray-200 bg-gray-50 px-2 py-1.5">
{showNewsBlockFeatures && (
<div className="relative">
<button
type="button"
onClick={() => setBlockMenuOpen((value) => !value)}
className="px-2 py-1 rounded text-xs font-medium text-gray-700 hover:bg-gray-200"
title="Block-Menü"
>
Block +
</button>
{blockMenuOpen && (
<div className="absolute top-8 left-0 z-20 w-56 rounded-md border border-gray-200 bg-white shadow-lg p-1">
{blockActions.map((action) => (
<button
key={action.id}
type="button"
onClick={() => applyBlockAction(action)}
className="w-full text-left px-3 py-2 text-sm rounded hover:bg-gray-100"
>
{action.label}
</button>
))}
</div>
)}
</div>
)}
<button
type="button"
onClick={() => editor.chain().focus().toggleBold().run()}
className={`${buttonClass(editor.isActive("bold"))} font-semibold`}
title="Fett"
>
B
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleItalic().run()}
className={`${buttonClass(editor.isActive("italic"))} italic`}
title="Kursiv"
>
I
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleUnderline().run()}
className={`${buttonClass(editor.isActive("underline"))} underline`}
title="Unterstrichen"
>
U
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleStrike().run()}
className={`${buttonClass(editor.isActive("strike"))} line-through`}
title="Durchgestrichen"
>
S
</button>
<span className="w-px h-4 bg-gray-300 mx-1" />
<button
type="button"
onClick={() => editor.chain().focus().setParagraph().run()}
className={buttonClass(editor.isActive("paragraph"))}
title="Absatz"
>
P
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={buttonClass(editor.isActive("heading", { level: 2 }))}
title="Überschrift 2"
>
H2
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
className={buttonClass(editor.isActive("heading", { level: 3 }))}
title="Überschrift 3"
>
H3
</button>
<span className="w-px h-4 bg-gray-300 mx-1" />
<button
type="button"
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={buttonClass(editor.isActive("bulletList"))}
title="Aufzählung"
>
Liste
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={buttonClass(editor.isActive("orderedList"))}
title="Nummerierte Liste"
>
1.
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={buttonClass(editor.isActive("blockquote"))}
title="Zitat"
>
Quote
</button>
{showNewsBlockFeatures && (
<>
<span className="w-px h-4 bg-gray-300 mx-1" />
<button
type="button"
onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}
className={buttonClass(editor.isActive("table"))}
title="Tabelle einfügen"
>
Tabelle
</button>
<button
type="button"
onClick={() => editor.chain().focus().addRowAfter().run()}
className={buttonClass(false)}
title="Zeile hinzufügen"
>
+Zeile
</button>
<button
type="button"
onClick={() => editor.chain().focus().addColumnAfter().run()}
className={buttonClass(false)}
title="Spalte hinzufügen"
>
+Spalte
</button>
<button
type="button"
onClick={() => editor.chain().focus().deleteTable().run()}
className={buttonClass(false)}
title="Tabelle entfernen"
>
-Tabelle
</button>
</>
)}
<span className="w-px h-4 bg-gray-300 mx-1" />
<button
type="button"
onClick={handleLink}
className={buttonClass(editor.isActive("link"))}
title="Link einfügen"
>
Link
</button>
<button
type="button"
onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()}
className={buttonClass(false)}
title="Formatierung entfernen"
>
Reset
</button>
<span className="w-px h-4 bg-gray-300 mx-1" />
<button
type="button"
onClick={() => editor.chain().focus().setTextAlign("left").run()}
className={buttonClass(editor.isActive({ textAlign: "left" }))}
title="Linksbündig"
>
L
</button>
<button
type="button"
onClick={() => editor.chain().focus().setTextAlign("center").run()}
className={buttonClass(editor.isActive({ textAlign: "center" }))}
title="Zentriert"
>
C
</button>
<button
type="button"
onClick={() => editor.chain().focus().setTextAlign("right").run()}
className={buttonClass(editor.isActive({ textAlign: "right" }))}
title="Rechtsbündig"
>
R
</button>
<span className="w-px h-4 bg-gray-300 mx-1" />
<button
type="button"
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
className={`${buttonClass(false)} disabled:opacity-40`}
title="Rückgängig"
>
Undo
</button>
<button
type="button"
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
className={`${buttonClass(false)} disabled:opacity-40`}
title="Wiederholen"
>
Redo
</button>
</div>
<EditorContent
editor={editor}
className={`w-full px-3 py-2 text-sm rounded-b-md focus-within:ring-1 focus-within:ring-[#0F766E] ${minHeightClassName} [&_.ProseMirror]:outline-none [&_.ProseMirror]:min-h-[inherit] [&_.ProseMirror_h2]:text-xl [&_.ProseMirror_h2]:font-semibold [&_.ProseMirror_h3]:text-lg [&_.ProseMirror_h3]:font-semibold [&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ul]:ml-5 [&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ol]:ml-5 [&_.ProseMirror_blockquote]:border-l-4 [&_.ProseMirror_blockquote]:border-gray-300 [&_.ProseMirror_blockquote]:pl-3 [&_.ProseMirror_a]:text-[#0F766E] [&_.ProseMirror_a]:underline [&_.ProseMirror_table]:w-full [&_.ProseMirror_table]:border-collapse [&_.ProseMirror_table]:border [&_.ProseMirror_td]:border [&_.ProseMirror_td]:border-gray-300 [&_.ProseMirror_td]:p-2 [&_.ProseMirror_th]:border [&_.ProseMirror_th]:border-gray-300 [&_.ProseMirror_th]:p-2 [&_.ProseMirror_th]:bg-gray-100`}
/>
</div>
{showNewsBlockFeatures && slashMenu.open && (
<div
className="absolute z-30 w-60 rounded-md border border-gray-200 bg-white shadow-lg p-1"
style={{ top: slashMenu.top, left: slashMenu.left }}
>
<p className="px-3 py-1 text-[11px] text-gray-500">Slash-Menü</p>
{blockActions.map((action) => (
<button
key={`slash-${action.id}`}
type="button"
onClick={() => applyBlockAction(action)}
className="w-full text-left px-3 py-2 text-sm rounded hover:bg-gray-100"
>
{action.label}
</button>
))}
</div>
)}
</div>
);
}