import React, { useRef, useEffect, useState, useCallback } from 'react' function exec(cmd, value = null) { try { return document.execCommand(cmd, false, value) } catch (_) { return false } } /** 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 */ } } /** 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') } 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) } /** * Leichter WYSIWYG (contenteditable). Wert kommt von außen zuverlässig ins DOM (Edit-Modus). * @param {{ id: number, label: string }[]} [insertExerciseMediaSlots] — §11 Verweise auf exercise_media.id */ export default function RichTextEditor({ value, onChange, placeholder, minHeight = '140px', insertExerciseMediaSlots, }) { const ref = useRef(null) const [focused, setFocused] = useState(false) useEffect(() => { const el = ref.current if (!el || focused) return const next = value ?? '' if (el.innerHTML !== next) { el.innerHTML = next } }, [value, focused]) const sync = useCallback(() => { if (!ref.current) return onChange(ref.current.innerHTML) }, [onChange]) const run = (fn) => (e) => { e.preventDefault() 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 */ } fn() sync() } const onLink = (e) => { e.preventDefault() e.stopPropagation() const el = ref.current if (!el) return const saved = saveSelectionInside(el) el.focus() restoreSelection(saved) const url = window.prompt('Link-URL (https://…)') if (url) { exec('createLink', url) sync() } } 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() } return (