import React, { useRef, useEffect, useState, useCallback } from 'react' import ExerciseInlineFileMediaModal from './ExerciseInlineFileMediaModal' import ExerciseInlineEmbedModal from './ExerciseInlineEmbedModal' 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 buildInlineExerciseMediaHtml(mediaId, displaySize = 'medium') { const sid = parseInt(String(mediaId), 10) if (!Number.isFinite(sid) || sid < 1) return null const sz = ['small', 'medium', 'full'].includes(displaySize) ? displaySize : 'medium' return `\u2060` } function insertExerciseMediaPlaceholder(editorEl, mediaId, displaySize = 'medium') { if (!editorEl || mediaId == null) return false const html = buildInlineExerciseMediaHtml(mediaId, displaySize) if (!html) return false editorEl.focus() const sel = window.getSelection() if (!sel) return false let caretInside = false if (sel.rangeCount > 0) { try { const r0 = sel.getRangeAt(0) caretInside = editorEl.contains(r0.commonAncestorContainer) } catch { caretInside = false } } if (!caretInside) { const anchor = document.createRange() try { anchor.selectNodeContents(editorEl) anchor.collapse(false) } catch { return false } sel.removeAllRanges() sel.addRange(anchor) } let inserted = false try { inserted = document.execCommand('insertHTML', false, html) } catch { inserted = false } if (inserted) return true let range = null try { range = sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null } catch { range = null } if (!range || !editorEl.contains(range.commonAncestorContainer)) { range = document.createRange() range.selectNodeContents(editorEl) range.collapse(false) sel.removeAllRanges() sel.addRange(range) } try { const tpl = document.createElement('template') tpl.innerHTML = html const span = tpl.content.firstChild if (!span) return false range.deleteContents() range.insertNode(span) range.setStartAfter(span) range.collapse(true) sel.removeAllRanges() sel.addRange(range) return true } catch { return false } } /** * Leichter WYSIWYG (contenteditable). * @param {{ inlineExerciseId?: number|null, onExerciseMediaListChanged?: () => Promise }} [extra] */ export default function RichTextEditor({ value, onChange, placeholder, minHeight = '140px', inlineExerciseId = null, onExerciseMediaListChanged, }) { const ref = useRef(null) const pendingRangeRef = useRef(null) const [focused, setFocused] = useState(false) const [fileModalOpen, setFileModalOpen] = useState(false) const [embedModalOpen, setEmbedModalOpen] = 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 refreshExerciseMedia = useCallback(async () => { if (onExerciseMediaListChanged) { await onExerciseMediaListChanged() } }, [onExerciseMediaListChanged]) const stashRangeAndOpen = useCallback((openFn) => (e) => { e.preventDefault() e.stopPropagation() const el = ref.current pendingRangeRef.current = el ? saveSelectionInside(el) : null openFn() }, []) const finalizeInsertFromModal = useCallback( (mediaId, displaySize) => { queueMicrotask(() => { const shell = ref.current if (!shell) return shell.focus() restoreSelection(pendingRangeRef.current) const ok = insertExerciseMediaPlaceholder(shell, mediaId, displaySize) if (!ok) { alert( 'Einfügen ist fehlgeschlagen — bitte Cursor ins Textfeld setzen und den Schalter erneut verwenden.', ) return } sync() shell.focus() }) }, [sync], ) 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 showInlineToolbar = inlineExerciseId != null && Number(inlineExerciseId) > 0 return (
{showInlineToolbar ? ( <> ) : null}
setFocused(true)} onBlur={() => { setFocused(false) sync() }} onInput={sync} /> {showInlineToolbar ? ( setFileModalOpen(false)} exerciseId={Number(inlineExerciseId)} onMediaListChanged={refreshExerciseMedia} onInserted={(mid, sz) => finalizeInsertFromModal(mid, sz)} /> ) : null} {showInlineToolbar ? ( setEmbedModalOpen(false)} exerciseId={Number(inlineExerciseId)} onMediaListChanged={refreshExerciseMedia} onInserted={(mid, sz) => finalizeInsertFromModal(mid, sz)} /> ) : null}
) }