import React, { useRef, useEffect, useState, useCallback } from 'react' import ExerciseInlineFileMediaModal from './ExerciseInlineFileMediaModal' import ExerciseInlineEmbedModal from './ExerciseInlineEmbedModal' import { sanitizeInlineMediaCaption } from '../utils/inlineMediaCaption' import { SHINKAN_EXERCISE_MEDIA_DRAG_MIME, parseExerciseMediaDragPayload, } from '../utils/exerciseInlineMediaRefs' import { autoScrollForDragNearEdges } from '../utils/dragAutoScroll' 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 escapeHtmlAttr(s) { return String(s) .replace(/&/g, '&') .replace(/"/g, '"') .replace(/\u2060` } function patchExitGlyphAfterPlaceholder(editorEl, mediaId, sel) { const sid = String(parseInt(String(mediaId), 10)) const hits = editorEl.querySelectorAll(`span.shinkan-inline-media[data-shinkan-exercise-media="${sid}"]`) const span = hits[hits.length - 1] if (!span?.parentNode || !sel) return let n = span.nextSibling if (!n || n.nodeType !== Node.TEXT_NODE || !n.textContent.includes('\u200B')) { const zn = document.createTextNode('\u200B') span.parentNode.insertBefore(zn, span.nextSibling) n = zn } try { const r = document.createRange() r.setStart(n, n.textContent.length) r.collapse(true) sel.removeAllRanges() sel.addRange(r) } catch { /* noop */ } } function insertExerciseMediaPlaceholder(editorEl, mediaId, displaySize = 'medium', caption = '') { if (!editorEl || mediaId == null) return false const html = buildInlineExerciseMediaHtml(mediaId, displaySize, caption) 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) { patchExitGlyphAfterPlaceholder(editorEl, mediaId, sel) 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) const zn = document.createTextNode('\u200B') span.parentNode.insertBefore(zn, span.nextSibling) range.setStart(zn, zn.textContent.length) range.collapse(true) sel.removeAllRanges() sel.addRange(range) return true } catch { return false } } /** * Leichter WYSIWYG (contenteditable). * @param {{ * linkedExerciseMedia?: object[], * }} [extra] */ export default function RichTextEditor({ value, onChange, placeholder, minHeight = '140px', inlineExerciseId = null, linkedExerciseMedia = [], onExerciseMediaListChanged, }) { const ref = useRef(null) const pendingRangeRef = useRef(null) const [focused, setFocused] = useState(false) const [fileModalOpen, setFileModalOpen] = useState(false) const [embedModalOpen, setEmbedModalOpen] = useState(false) const showInlineToolbar = inlineExerciseId != null && Number(inlineExerciseId) > 0 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, caption) => { queueMicrotask(() => { const shell = ref.current if (!shell) return shell.focus() restoreSelection(pendingRangeRef.current) const ok = insertExerciseMediaPlaceholder(shell, mediaId, displaySize, caption) if (!ok) { alert( 'Einfügen ist fehlgeschlagen — bitte Cursor ins Textfeld setzen und den Schalter erneut verwenden.', ) return } sync() shell.focus() }) }, [sync], ) const onEditorKeyDown = useCallback( (e) => { const el = ref.current if (!el || e.key !== 'Enter') return const sel = window.getSelection() if (!sel || sel.rangeCount === 0 || !el.contains(sel.focusNode)) return let node = sel.focusNode let elNode = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement const host = elNode?.closest?.('.shinkan-inline-media') if (!host || !el.contains(host)) return e.preventDefault() patchExitGlyphAfterPlaceholder(el, host.getAttribute('data-shinkan-exercise-media'), sel) try { exec('insertParagraph') } catch { exec('insertLineBreak') } sync() }, [sync], ) const onEditorDragOver = useCallback( (e) => { if (!showInlineToolbar) return const types = e.dataTransfer?.types ? Array.from(e.dataTransfer.types) : [] if (types.includes(SHINKAN_EXERCISE_MEDIA_DRAG_MIME)) { e.preventDefault() e.dataTransfer.dropEffect = 'copy' autoScrollForDragNearEdges(e) } }, [showInlineToolbar], ) const onEditorDrop = useCallback( (e) => { const el = ref.current if (!el || !showInlineToolbar) return const raw = e.dataTransfer?.getData(SHINKAN_EXERCISE_MEDIA_DRAG_MIME) const parsed = parseExerciseMediaDragPayload(raw) if (!parsed) return e.preventDefault() el.focus() const sel = window.getSelection() if (!sel) return let r = null try { if (document.caretRangeFromPoint) { r = document.caretRangeFromPoint(e.clientX, e.clientY) } } catch { r = null } if (!r) { try { const p = document.caretPositionFromPoint?.(e.clientX, e.clientY) if (p?.offsetNode) { const nr = document.createRange() nr.setStart(p.offsetNode, p.offset) nr.collapse(true) r = nr } } catch { r = null } } if (r && el.contains(r.commonAncestorContainer)) { sel.removeAllRanges() sel.addRange(r) } else { const anchor = document.createRange() try { anchor.selectNodeContents(el) anchor.collapse(false) } catch { return } sel.removeAllRanges() sel.addRange(anchor) } insertExerciseMediaPlaceholder(el, parsed.exerciseMediaId, 'medium', parsed.caption) sync() el.focus() }, [sync, showInlineToolbar], ) 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() } } return (