// 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
); } type RichTextEditorProps = { value: string; onChange: (html: string) => void; placeholder?: string; maxLength?: number; ariaLabel?: string; }; export function RichTextEditor({ value, onChange, placeholder, maxLength, ariaLabel }: RichTextEditorProps) { const editor = useEditor({ extensions: [ StarterKit.configure({ heading: { levels: [3] } }), Underline, Link.configure({ openOnClick: false, autolink: true, HTMLAttributes: { rel: 'noopener noreferrer nofollow', target: '_blank' } }), Placeholder.configure({ placeholder: placeholder ?? '' }), ...(maxLength ? [CharacterCount.configure({ limit: maxLength })] : [CharacterCount]), ], content: value || '', editorProps: { attributes: { role: 'textbox', 'aria-multiline': 'true', ...(ariaLabel ? { 'aria-label': ariaLabel } : {}), }, }, onUpdate: ({ editor: current }) => { const html = current.getHTML(); // Pusty edytor zwraca "

" - normalizujemy do pustego ciagu. onChange(current.isEmpty ? '' : html); }, }); // Synchronizacja z zewnetrzna wartoscia (prefill w trybie edycji) - tylko gdy sie rozjecha. useEffect(() => { if (!editor) { return; } const incoming = value || ''; const currentEmpty = editor.isEmpty && incoming === ''; if (!currentEmpty && incoming !== editor.getHTML()) { editor.commands.setContent(incoming, false); } }, [editor, value]); const count = editor ? editor.storage.characterCount.characters() : stripHtml(value).length; return (
{editor && } {typeof maxLength === 'number' && ( {count}/{maxLength} )}
); }