// Wspoldzielony edytor tekstu sformatowanego (WYSIWYG) dla calego serwisu.
// Standard: pola prozą uzywaja RichTextEditor (zapis HTML), a render tresci
// uzytkownika idzie WYLACZNIE przez SafeHtml (sanityzacja DOMPurify) - to jedyny
// punkt renderowania HTML od uzytkownika, co chroni przed XSS.
import { useEffect, type ReactNode } from 'react';
import { EditorContent, useEditor, type Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Underline from '@tiptap/extension-underline';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import CharacterCount from '@tiptap/extension-character-count';
import DOMPurify from 'dompurify';
// Dozwolone znaczniki i atrybuty przy renderowaniu tresci uzytkownika.
const ALLOWED_TAGS = ['p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'ul', 'ol', 'li', 'h3', 'a'];
const ALLOWED_ATTR = ['href', 'target', 'rel'];
export function sanitizeHtml(html: string): string {
return DOMPurify.sanitize(html ?? '', { ALLOWED_TAGS, ALLOWED_ATTR });
}
// Widoczny tekst (bez znacznikow) - do walidacji i licznika znakow.
export function stripHtml(html: string): string {
if (!html) {
return '';
}
const doc = new DOMParser().parseFromString(sanitizeHtml(html), 'text/html');
return (doc.body.textContent || '').replace(/ /g, ' ').trim();
}
// Czy tresc jest pusta (sam pusty akapit tez traktujemy jako brak tresci).
export function isRichTextEmpty(html: string): boolean {
return stripHtml(html).length === 0;
}
type SafeHtmlProps = {
html: string;
className?: string;
as?: 'div' | 'span';
};
// Jedyny dozwolony sposob renderowania HTML pochodzacego od uzytkownika.
export function SafeHtml({ html, className, as = 'div' }: SafeHtmlProps) {
const Tag = as;
return ;
}
type ToolbarButtonProps = {
onClick: () => void;
active?: boolean;
disabled?: boolean;
label: string;
title: string;
children: ReactNode;
};
function ToolbarButton({ onClick, active, disabled, label, title, children }: ToolbarButtonProps) {
return (
);
}
function Toolbar({ editor }: { editor: Editor }) {
return (
editor.chain().focus().toggleBold().run()}>
B editor.chain().focus().toggleItalic().run()}>
I editor.chain().focus().toggleUnderline().run()}>
U editor.chain().focus().toggleHeading({ level: 3 }).run()}>
H
editor.chain().focus().toggleBulletList().run()}>
• —
editor.chain().focus().toggleOrderedList().run()}>
1.
editor.chain().focus().clearNodes().unsetAllMarks().run()}>
✕