@@ -49,3 +49,115 @@ initI18n({ resources: { en, de } });
4949Build: ` npm install && npm run build ` (tsup → ` dist/ ` ). Distribution als
5050npm-Tarball über ein GitHub-Release-Asset (siehe ADR-0002, ADR-0004 und
5151Repo-CI).
52+
53+ ## Rich text
54+
55+ ` RichTextEditor ` (TipTap) + ` RichText ` — der eine WYSIWYG-Editor für
56+ formatierte Langtext-Felder (Fett, Kursiv, Aufzählungen, nummerierte Listen,
57+ Link, Überschriften H2/H3, optional Bilder) und die passende Renderkomponente
58+ für das gespeicherte HTML. Aus AbstimmBAR in die Basis verschoben
59+ (modulierbar #5 ), damit alle -bar-Tools eine Implementierung teilen.
60+
61+ ** Sanitizing-Vertrag:** die Komponenten selbst sanitizen nichts — die
62+ Sicherheitsgrenze ist das Backend. Jedes Rich-Text-Feld muss beim Speichern
63+ (und beim Import) durch einen Allowlist-Sanitizer laufen, z. B.
64+ ` basicbar_integrations.html_sanitize.clean_html ` , der auf genau das Subset
65+ reduziert, das der Editor erzeugt (` p ` , ` strong ` , ` em ` , ` h2 ` , ` h3 ` , ` ul ` ,
66+ ` ol ` , ` li ` , ` a[href,rel] ` , ` img[src] ` , …). Bild-URLs müssen relativ sein
67+ (` /media/… ` ) — der Editor fügt nur ein, was ` onUploadImage ` zurückgibt, ohne
68+ es zu validieren. Der Editor selbst bietet bewusst ** nur** dieses Subset an:
69+ Unterstreichen/Durchgestrichen sind nicht registriert (TipTap-` underline ` /
70+ ` strike ` explizit aus) und Links tragen kein ` target ` /` rel ` (das Backend
71+ erzwingt ` rel="noopener" ` ohnehin) — eine Formatierung, die der Sanitizer
72+ später wieder entfernt, soll der Nutzer erst gar nicht setzen können.
73+
74+ ** Zugänglicher Name:** der Editor braucht immer entweder ` ariaLabel ` (kein
75+ sichtbares Label) oder ` labelledBy ` (Id eines bereits vorhandenen sichtbaren
76+ ` <label> ` -Elements, z. B. ` TranslatableField ` s ` labelId ` — siehe unten).
77+
78+ ** Editor ohne Bilder** (kein Upload-Endpunkt verdrahtet — kein Bild-Button,
79+ Drag&Drop/Einfügen aus der Zwischenablage werden ignoriert; bestehende
80+ ` <img> ` -Inhalte bleiben trotzdem sichtbar):
81+
82+ ``` tsx
83+ import { RichTextEditor } from " @basicbar/ui" ;
84+
85+ <RichTextEditor
86+ value = { description }
87+ onChange = { setDescription }
88+ ariaLabel = { t (" Description" )}
89+ />
90+ ```
91+
92+ ** Editor mit Bildern** — ` onUploadImage ` lädt hoch und liefert die relative
93+ URL als String; scheitert der Upload, zeigt der Editor
94+ ` t("Image upload failed") ` (plus die Fehlermeldung, falls vorhanden) per
95+ ` window.alert ` und lässt den Inhalt unverändert:
96+
97+ ``` tsx
98+ <RichTextEditor
99+ value = { description }
100+ onChange = { setDescription }
101+ onUploadImage = { async (file ) => {
102+ // Eigener Upload-Endpunkt der App; liefert z. B. {"url": "/media/rich/x.png"}.
103+ const { url } = await postImage (file );
104+ return url ; // relative URL als String
105+ }}
106+ id = " description-editor"
107+ ariaLabel = { t (" Description" )}
108+ />
109+ ```
110+
111+ ** Bilder aus eingefügtem HTML werden gefiltert, nicht nur Datei-Paste/-Drop:**
112+ Fügt man Rich-HTML aus einer Webseite ein (Browser-Copy&Paste, nicht als
113+ Datei), landet es über ProseMirrors HTML-Parser im Dokument — ein
114+ ` <img src="https://…"> ` würde sonst am ` onUploadImage ` -Flow vorbei direkt
115+ eingefügt und wäre nach dem Speichern ein kaputtes ` <img> ` (der Backend-
116+ Sanitizer erlaubt nur ` /media/… ` -Quellen). Der Editor filtert deshalb per
117+ ` transformPastedHTML ` jedes eingefügte ` <img> ` , dessen ` src ` nicht mit
118+ ` /media/ ` beginnt; ohne ` onUploadImage ` wird jedes eingefügte ` <img> `
119+ entfernt (Bilder sind dann vollständig deaktiviert). Das betrifft nur
120+ eingefügtes HTML — vorhandene Bilder im initialen ` value ` bleiben
121+ unangetastet.
122+
123+ ** Rendern** des serverseitig sanitisierten HTML:
124+
125+ ``` tsx
126+ import { RichText } from " @basicbar/ui" ;
127+
128+ <RichText html = { product .description } />
129+ // eigene Klassen statt des Prosa-Defaults (ersetzt, nicht ergänzt):
130+ <RichText html = { product .description } className = " text-3xl [&_img]:max-h-64" />
131+ ```
132+
133+ ** Integration in ` TranslatableField ` ** über ` renderInput ` (pro Sprache ein
134+ Editor, mit ` format="html" ` bleiben die Ausgefüllt-Punkte markup-blind und
135+ die Maschinenübersetzung erhält die Tags). ` renderInput ` bekommt neben ` id `
136+ auch ` labelId ` — die Id von ` TranslatableField ` s eigenem sichtbaren ` <label> `
137+ (nur gesetzt, wenn die ` label ` -Prop übergeben wurde); durchgereicht als
138+ ` labelledBy ` bindet der Editor sich per ` aria-labelledby ` an dieses Label,
139+ statt ein zweites, redundantes ` ariaLabel ` zu brauchen:
140+
141+ ``` tsx
142+ <TranslatableField
143+ label = { t (" Description" )}
144+ values = { { de: form .description_de , en: form .description_en }}
145+ onChange = { (lang , html ) => setField (` description_${lang } ` , html )}
146+ format = " html"
147+ renderInput = { ({ value , onChange , id , labelId }) => (
148+ <RichTextEditor
149+ value = { value }
150+ onChange = { onChange }
151+ onUploadImage = { uploadRichImage }
152+ id = { id }
153+ labelledBy = { labelId }
154+ />
155+ )}
156+ />
157+ ```
158+
159+ ** Übersetzungs-Keys** , die das Tool bereitstellen muss (Englisch als Key,
160+ siehe ` initI18n ` ): ` "Bold" ` , ` "Italic" ` , ` "Heading (large)" ` ,
161+ ` "Heading (small)" ` , ` "Bulleted list" ` , ` "Numbered list" ` , ` "Link" ` ,
162+ ` "Enter URL" ` , ` "Insert image (or drag and drop)" ` ,
163+ ` "Image upload failed" ` .
0 commit comments