Files
polskalokalnie/frontend/src/RichText.tsx
T

160 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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()}>
&bull; &#8212;
</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()}>
&#10005;
</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>
);
}