160 lines
6.0 KiB
TypeScript
160 lines
6.0 KiB
TypeScript
// 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 <Tag className={className} dangerouslySetInnerHTML={{ __html: sanitizeHtml(html) }} />;
|
||
}
|
||
|
||
type ToolbarButtonProps = {
|
||
onClick: () => void;
|
||
active?: boolean;
|
||
disabled?: boolean;
|
||
label: string;
|
||
title: string;
|
||
children: ReactNode;
|
||
};
|
||
|
||
function ToolbarButton({ onClick, active, disabled, label, title, children }: ToolbarButtonProps) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={active ? 'active' : ''}
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
aria-label={label}
|
||
aria-pressed={active}
|
||
title={title}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function Toolbar({ editor }: { editor: Editor }) {
|
||
return (
|
||
<div className="rich-editor-toolbar" role="toolbar" aria-label="Formatowanie tekstu">
|
||
<ToolbarButton label="Pogrubienie" title="Pogrubienie" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
|
||
<strong>B</strong>
|
||
</ToolbarButton>
|
||
<ToolbarButton label="Kursywa" title="Kursywa" active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}>
|
||
<em>I</em>
|
||
</ToolbarButton>
|
||
<ToolbarButton label="Podkreslenie" title="Podkreslenie" active={editor.isActive('underline')} onClick={() => editor.chain().focus().toggleUnderline().run()}>
|
||
<span style={{ textDecoration: 'underline' }}>U</span>
|
||
</ToolbarButton>
|
||
<span className="rich-editor-sep" aria-hidden="true" />
|
||
<ToolbarButton label="Naglowek" title="Naglowek" active={editor.isActive('heading', { level: 3 })} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>
|
||
H
|
||
</ToolbarButton>
|
||
<ToolbarButton label="Lista punktowana" title="Lista punktowana" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
|
||
• —
|
||
</ToolbarButton>
|
||
<ToolbarButton label="Lista numerowana" title="Lista numerowana" active={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
|
||
1.
|
||
</ToolbarButton>
|
||
<span className="rich-editor-sep" aria-hidden="true" />
|
||
<ToolbarButton label="Wyczysc formatowanie" title="Wyczysc formatowanie" onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}>
|
||
✕
|
||
</ToolbarButton>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 "<p></p>" - 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 (
|
||
<div className="rich-editor">
|
||
{editor && <Toolbar editor={editor} />}
|
||
<EditorContent editor={editor} className="rich-editor-area" />
|
||
{typeof maxLength === 'number' && (
|
||
<small className="rich-editor-count">{count}/{maxLength}</small>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|