2026-04-27 14:48:46 +02:00
|
|
|
import React, { useRef, useEffect, useState, useCallback } from 'react'
|
|
|
|
|
|
|
|
|
|
function exec(cmd, value = null) {
|
|
|
|
|
try {
|
2026-04-27 15:01:47 +02:00
|
|
|
return document.execCommand(cmd, false, value)
|
2026-04-27 14:48:46 +02:00
|
|
|
} catch (_) {
|
2026-04-27 15:01:47 +02:00
|
|
|
return false
|
2026-04-27 14:48:46 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 13:09:42 +02:00
|
|
|
/** Selection speichern, bevor Toolbar-Klicks sie zerstört (mousedown + preventDefault allein reicht nicht überall). */
|
|
|
|
|
function saveSelectionInside(editorEl) {
|
|
|
|
|
const sel = window.getSelection()
|
|
|
|
|
if (!sel || sel.rangeCount === 0 || !editorEl) return null
|
|
|
|
|
try {
|
|
|
|
|
const range = sel.getRangeAt(0)
|
|
|
|
|
if (!editorEl.contains(range.commonAncestorContainer)) return null
|
|
|
|
|
return range.cloneRange()
|
|
|
|
|
} catch {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function restoreSelection(range) {
|
|
|
|
|
if (!range) return
|
|
|
|
|
try {
|
|
|
|
|
const sel = window.getSelection()
|
|
|
|
|
sel.removeAllRanges()
|
|
|
|
|
sel.addRange(range)
|
|
|
|
|
} catch {
|
|
|
|
|
/* noop */
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 15:01:47 +02:00
|
|
|
/** Browser: formatBlock erwartet oft Tag in Großschreibung. */
|
|
|
|
|
function formatBlock(tag) {
|
|
|
|
|
const t = String(tag).toUpperCase()
|
|
|
|
|
if (!exec('formatBlock', t)) {
|
|
|
|
|
exec('formatBlock', tag.toLowerCase())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalText() {
|
|
|
|
|
exec('removeFormat')
|
|
|
|
|
formatBlock('p')
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 14:48:46 +02:00
|
|
|
/**
|
2026-04-27 15:01:47 +02:00
|
|
|
* Leichter WYSIWYG (contenteditable). Wert kommt von außen zuverlässig ins DOM (Edit-Modus).
|
2026-04-27 14:48:46 +02:00
|
|
|
*/
|
|
|
|
|
export default function RichTextEditor({ value, onChange, placeholder, minHeight = '140px' }) {
|
|
|
|
|
const ref = useRef(null)
|
|
|
|
|
const [focused, setFocused] = useState(false)
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-04-27 15:01:47 +02:00
|
|
|
const el = ref.current
|
|
|
|
|
if (!el || focused) return
|
|
|
|
|
const next = value ?? ''
|
|
|
|
|
if (el.innerHTML !== next) {
|
|
|
|
|
el.innerHTML = next
|
2026-04-27 14:48:46 +02:00
|
|
|
}
|
|
|
|
|
}, [value, focused])
|
|
|
|
|
|
|
|
|
|
const sync = useCallback(() => {
|
|
|
|
|
if (!ref.current) return
|
2026-04-27 15:01:47 +02:00
|
|
|
onChange(ref.current.innerHTML)
|
2026-04-27 14:48:46 +02:00
|
|
|
}, [onChange])
|
|
|
|
|
|
2026-04-27 15:01:47 +02:00
|
|
|
const run = (fn) => (e) => {
|
|
|
|
|
e.preventDefault()
|
2026-04-28 13:09:42 +02:00
|
|
|
e.stopPropagation()
|
|
|
|
|
const el = ref.current
|
|
|
|
|
if (!el) return
|
|
|
|
|
const saved = saveSelectionInside(el)
|
|
|
|
|
el.focus()
|
|
|
|
|
restoreSelection(saved)
|
|
|
|
|
try {
|
|
|
|
|
document.execCommand('styleWithCSS', false, false)
|
|
|
|
|
} catch {
|
|
|
|
|
/* optional */
|
|
|
|
|
}
|
2026-04-27 15:01:47 +02:00
|
|
|
fn()
|
2026-04-27 14:48:46 +02:00
|
|
|
sync()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 15:01:47 +02:00
|
|
|
const onLink = (e) => {
|
|
|
|
|
e.preventDefault()
|
2026-04-28 13:09:42 +02:00
|
|
|
e.stopPropagation()
|
|
|
|
|
const el = ref.current
|
|
|
|
|
if (!el) return
|
|
|
|
|
const saved = saveSelectionInside(el)
|
|
|
|
|
el.focus()
|
|
|
|
|
restoreSelection(saved)
|
2026-04-27 15:01:47 +02:00
|
|
|
const url = window.prompt('Link-URL (https://…)')
|
|
|
|
|
if (url) {
|
|
|
|
|
exec('createLink', url)
|
|
|
|
|
sync()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 14:48:46 +02:00
|
|
|
return (
|
|
|
|
|
<div className="rich-text-editor-wrap">
|
|
|
|
|
<div className="rich-text-toolbar" role="toolbar" aria-label="Formatierung">
|
2026-04-27 15:01:47 +02:00
|
|
|
<button type="button" className="rte-btn" title="Fett" onMouseDown={run(() => exec('bold'))}>
|
2026-04-27 14:48:46 +02:00
|
|
|
<strong>B</strong>
|
|
|
|
|
</button>
|
2026-04-27 15:01:47 +02:00
|
|
|
<button type="button" className="rte-btn" title="Kursiv" onMouseDown={run(() => exec('italic'))}>
|
2026-04-27 14:48:46 +02:00
|
|
|
<em>I</em>
|
|
|
|
|
</button>
|
2026-04-27 15:01:47 +02:00
|
|
|
<button type="button" className="rte-btn" title="Unterstrichen" onMouseDown={run(() => exec('underline'))}>
|
2026-04-27 14:48:46 +02:00
|
|
|
U
|
|
|
|
|
</button>
|
|
|
|
|
<span className="rte-sep" />
|
2026-04-27 15:01:47 +02:00
|
|
|
<button type="button" className="rte-btn" title="Normaler Absatz" onMouseDown={run(normalText)}>
|
|
|
|
|
Normal
|
2026-04-27 14:48:46 +02:00
|
|
|
</button>
|
2026-04-27 15:01:47 +02:00
|
|
|
<button type="button" className="rte-btn" title="Zwischenüberschrift" onMouseDown={run(() => formatBlock('h3'))}>
|
|
|
|
|
Ü3
|
2026-04-27 14:48:46 +02:00
|
|
|
</button>
|
2026-04-27 15:01:47 +02:00
|
|
|
<span className="rte-sep" />
|
2026-04-28 13:09:42 +02:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="rte-btn"
|
|
|
|
|
title="Aufzählung (Mehrzeilen-Markierung möglich)"
|
|
|
|
|
onMouseDown={run(() => exec('insertUnorderedList'))}
|
|
|
|
|
>
|
2026-04-27 15:01:47 +02:00
|
|
|
• Liste
|
2026-04-27 14:48:46 +02:00
|
|
|
</button>
|
2026-04-27 15:01:47 +02:00
|
|
|
<button type="button" className="rte-btn" title="Nummerierte Liste" onMouseDown={run(() => exec('insertOrderedList'))}>
|
|
|
|
|
1. Liste
|
2026-04-27 14:48:46 +02:00
|
|
|
</button>
|
|
|
|
|
<span className="rte-sep" />
|
2026-04-27 15:01:47 +02:00
|
|
|
<button type="button" className="rte-btn" title="Link einfügen" onMouseDown={onLink}>
|
2026-04-27 14:48:46 +02:00
|
|
|
Link
|
|
|
|
|
</button>
|
2026-04-27 15:01:47 +02:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="rte-btn"
|
|
|
|
|
title="Zeichenformatierung am Cursor entfernen"
|
|
|
|
|
onMouseDown={run(() => {
|
|
|
|
|
exec('removeFormat')
|
|
|
|
|
exec('unlink')
|
|
|
|
|
})}
|
|
|
|
|
>
|
|
|
|
|
␡
|
2026-04-27 14:48:46 +02:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
ref={ref}
|
|
|
|
|
className="rich-text-editor"
|
|
|
|
|
contentEditable
|
|
|
|
|
suppressContentEditableWarning
|
|
|
|
|
data-placeholder={placeholder || ''}
|
|
|
|
|
style={{ minHeight }}
|
|
|
|
|
onFocus={() => setFocused(true)}
|
|
|
|
|
onBlur={() => {
|
|
|
|
|
setFocused(false)
|
|
|
|
|
sync()
|
|
|
|
|
}}
|
|
|
|
|
onInput={sync}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|