Skip to content

Commit 3dd0ba8

Browse files
authored
Merge pull request #5 from virtUOS/feat/ui-rich-text-editor
feat(ui): RichTextEditor + RichText (TipTap), moved from AbstimmBAR — ui 0.4.0
2 parents cf5fe9d + 1b64148 commit 3dd0ba8

8 files changed

Lines changed: 1234 additions & 12 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,54 @@ Changelog ist die Upgrade-Anleitung für die Tools.
77

88
## [Unreleased]
99

10+
### @basicbar/ui (→ wird `ui/v0.4.0`)
11+
12+
**`RichTextEditor` + `RichText`** (modulierbar#5), aus AbstimmBAR verschoben:
13+
der eine WYSIWYG-Editor (TipTap) für formatierte Langtext-Felder — Fett,
14+
Kursiv, Aufzählungen, nummerierte Listen, Link, Überschriften (H2/H3) und
15+
optional Bilder — plus `RichText` zum Rendern des gespeicherten HTML.
16+
Bilder (Toolbar-Button, Drag&Drop, Einfügen aus der Zwischenablage) gibt es
17+
nur, wenn die App `onUploadImage(file) => Promise<string>` übergibt (relative
18+
URL, z. B. `/media/rich/x.png`); ohne die Prop bleiben bestehende `<img>`-
19+
Inhalte sichtbar, es lassen sich nur keine neuen einfügen. Auf
20+
Upload-Fehler zeigt der Editor `t("Image upload failed")` per
21+
`window.alert` und lässt den Inhalt unverändert. Passt zum
22+
Sanitizing-Vertrag von `basicbar_integrations.html_sanitize.clean_html`
23+
(oder einem gleichwertigen Allowlist-Sanitizer im Tool) — die gespeicherte
24+
HTML wird dort auf genau dieses Subset reduziert. Eingefügtes Rich-HTML
25+
(z. B. aus einer Webseite kopiert) wird per `transformPastedHTML` gefiltert:
26+
jedes `<img>`, dessen `src` nicht mit `/media/` beginnt, wird entfernt —
27+
ohne `onUploadImage` wird jedes eingefügte `<img>` entfernt. So kann kein
28+
externes Bild am Upload-Flow vorbei ins Dokument gelangen; vorhandene Bilder
29+
im initialen `value` bleiben unangetastet.
30+
31+
Neue Dependencies: `@tiptap/react`, `@tiptap/pm`, `@tiptap/starter-kit`,
32+
`@tiptap/extension-link`, `@tiptap/extension-image`,
33+
`@tiptap/extension-bold` (alle `^3.27.2`); `react-dom` (`>=18`) ist jetzt
34+
zusätzlich zu `react` als Peer-Dependency deklariert (`@tiptap/react`
35+
importiert es zur Laufzeit).
36+
37+
Der Editor bietet bewusst nur das Subset an, das der Sanitizer behält:
38+
Unterstreichen/Durchgestrichen sind nicht registriert und Links tragen kein
39+
`target`/`rel` (das Backend erzwingt `rel="noopener"` ohnehin). Der externe
40+
`value`-Sync (Sprachwechsel u. ä.) läuft jetzt außerhalb der Undo-History
41+
(`setMeta("addToHistory", false)`), damit ein Undo direkt danach nicht in
42+
den vorherigen Inhalt hineinspringt. `RichTextEditor` hat eine neue optionale
43+
Prop `labelledBy` (Id eines externen `<label>`, Alternative zu `ariaLabel`);
44+
`RenderInputArgs` (für `renderInput`) hat entsprechend ein neues optionales
45+
Feld `labelId` — `TranslatableField`s eigene Label-Id, additiv und
46+
abwärtskompatibel. Der Bild-Upload-Button ist jetzt per `aria-label` benannt
47+
und zeigt einen sichtbaren Fokusring; ein fehlgeschlagener Bild-Upload zeigt
48+
zusätzlich zu `t("Image upload failed")` die Fehlermeldung, falls vorhanden.
49+
50+
Migration: keine für Tools, die die Komponente noch nicht nutzen — additiv.
51+
Ein Tool, das den Editor einsetzt, übernimmt `RichTextEditor`/`RichText`
52+
aus `@basicbar/ui` statt einer lokalen Kopie (z. B. via
53+
`TranslatableField`s `renderInput`, `labelledBy={labelId}` durchreichen) und
54+
ergänzt die Übersetzungs-Keys `"Bold"`, `"Italic"`, `"Heading (large)"`,
55+
`"Heading (small)"`, `"Bulleted list"`, `"Numbered list"`, `"Link"`,
56+
`"Enter URL"`, `"Insert image (or drag and drop)"`, `"Image upload failed"`.
57+
1058
### @basicbar/ui (→ wird `ui/v0.3.1`)
1159

1260
**Globaler Sprach-Umschalter** (modulierbar#99): Der schwebende

‎packages/ui/README.md‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,115 @@ initI18n({ resources: { en, de } });
4949
Build: `npm install && npm run build` (tsup → `dist/`). Distribution als
5050
npm-Tarball über ein GitHub-Release-Asset (siehe ADR-0002, ADR-0004 und
5151
Repo-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

Comments
 (0)