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-05-08 11:44:29 +02:00
|
|
|
function insertExerciseMediaPlaceholder(editorEl, mediaId) {
|
|
|
|
|
if (!editorEl || mediaId == null) return
|
|
|
|
|
const sid = parseInt(String(mediaId), 10)
|
|
|
|
|
if (!Number.isFinite(sid) || sid < 1) return
|
|
|
|
|
editorEl.focus()
|
|
|
|
|
const sel = window.getSelection()
|
|
|
|
|
if (!sel) return
|
|
|
|
|
let range = null
|
|
|
|
|
if (sel?.rangeCount) {
|
|
|
|
|
try {
|
|
|
|
|
const r0 = sel.getRangeAt(0)
|
|
|
|
|
if (editorEl.contains(r0.commonAncestorContainer)) range = r0
|
|
|
|
|
} catch {
|
|
|
|
|
/* ignore */
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!range) {
|
|
|
|
|
range = document.createRange()
|
|
|
|
|
range.selectNodeContents(editorEl)
|
|
|
|
|
range.collapse(false)
|
|
|
|
|
}
|
|
|
|
|
const span = document.createElement('span')
|
|
|
|
|
span.setAttribute('data-shinkan-exercise-media', String(sid))
|
|
|
|
|
span.className = 'shinkan-inline-media'
|
|
|
|
|
span.appendChild(document.createTextNode('\u2060'))
|
|
|
|
|
range.deleteContents()
|
|
|
|
|
range.insertNode(span)
|
|
|
|
|
range.setStartAfter(span)
|
|
|
|
|
range.collapse(true)
|
|
|
|
|
sel.removeAllRanges()
|
|
|
|
|
sel.addRange(range)
|
|
|
|
|
}
|
|
|
|
|
|
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-05-08 11:44:29 +02:00
|
|
|
* @param {{ id: number, label: string }[]} [insertExerciseMediaSlots] — §11 Verweise auf exercise_media.id
|
2026-04-27 14:48:46 +02:00
|
|
|
*/
|
2026-05-08 11:44:29 +02:00
|
|
|
export default function RichTextEditor({
|
|
|
|
|
value,
|
|
|
|
|
onChange,
|
|
|
|
|
placeholder,
|
|
|
|
|
minHeight = '140px',
|
|
|
|
|
insertExerciseMediaSlots,
|
|
|
|
|
}) {
|
2026-04-27 14:48:46 +02:00
|
|
|
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-05-08 11:44:29 +02:00
|
|
|
const showMediaPick = Array.isArray(insertExerciseMediaSlots) && insertExerciseMediaSlots.length > 0
|
|
|
|
|
|
|
|
|
|
const onInsertExerciseMediaClick = (e) => {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
e.stopPropagation()
|
|
|
|
|
const el = ref.current
|
|
|
|
|
if (!el || !insertExerciseMediaSlots?.length) return
|
|
|
|
|
let choice = ''
|
|
|
|
|
if (insertExerciseMediaSlots.length === 1) {
|
|
|
|
|
choice = String(insertExerciseMediaSlots[0].id)
|
|
|
|
|
} else {
|
|
|
|
|
choice = window.prompt(
|
|
|
|
|
`Medium-ID eingeben oder aus Liste:\n${insertExerciseMediaSlots
|
|
|
|
|
.slice(0, 30)
|
|
|
|
|
.map((s) => `${s.id}: ${s.label}`)
|
|
|
|
|
.join('\n')}`,
|
|
|
|
|
'',
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
const idParsed = parseInt(String(choice).trim(), 10)
|
|
|
|
|
if (!Number.isFinite(idParsed)) return
|
|
|
|
|
if (!insertExerciseMediaSlots.some((s) => Number(s.id) === idParsed)) {
|
|
|
|
|
alert('Diese Übungs-ID ist nicht in der Medienliste.')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const saved = saveSelectionInside(el)
|
|
|
|
|
el.focus()
|
|
|
|
|
restoreSelection(saved)
|
|
|
|
|
insertExerciseMediaPlaceholder(el, idParsed)
|
|
|
|
|
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-05-08 11:44:29 +02:00
|
|
|
{showMediaPick ? (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="rte-btn"
|
|
|
|
|
title="Übungs-Medium inline einfügen (§11)"
|
|
|
|
|
onMouseDown={onInsertExerciseMediaClick}
|
|
|
|
|
>
|
|
|
|
|
Bild/Video im Text
|
|
|
|
|
</button>
|
|
|
|
|
) : null}
|
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>
|
|
|
|
|
)
|
|
|
|
|
}
|