2026-04-27 14:48:46 +02:00
|
|
|
import React, { useRef, useEffect, useState, useCallback } from 'react'
|
2026-05-08 12:00:02 +02:00
|
|
|
import ExerciseInlineFileMediaModal from './ExerciseInlineFileMediaModal'
|
|
|
|
|
import ExerciseInlineEmbedModal from './ExerciseInlineEmbedModal'
|
2026-04-27 14:48:46 +02:00
|
|
|
|
|
|
|
|
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 12:00:02 +02:00
|
|
|
function buildInlineExerciseMediaHtml(mediaId, displaySize = 'medium') {
|
2026-05-08 11:44:29 +02:00
|
|
|
const sid = parseInt(String(mediaId), 10)
|
2026-05-08 12:00:02 +02:00
|
|
|
if (!Number.isFinite(sid) || sid < 1) return null
|
|
|
|
|
const sz = ['small', 'medium', 'full'].includes(displaySize) ? displaySize : 'medium'
|
|
|
|
|
return `<span data-shinkan-exercise-media="${sid}" data-shinkan-exercise-media-size="${sz}" class="shinkan-inline-media">\u2060</span>`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function insertExerciseMediaPlaceholder(editorEl, mediaId, displaySize = 'medium') {
|
|
|
|
|
if (!editorEl || mediaId == null) return false
|
|
|
|
|
const html = buildInlineExerciseMediaHtml(mediaId, displaySize)
|
|
|
|
|
if (!html) return false
|
2026-05-08 11:51:28 +02:00
|
|
|
|
2026-05-08 11:44:29 +02:00
|
|
|
editorEl.focus()
|
|
|
|
|
const sel = window.getSelection()
|
2026-05-08 11:51:28 +02:00
|
|
|
if (!sel) return false
|
|
|
|
|
|
|
|
|
|
let caretInside = false
|
|
|
|
|
if (sel.rangeCount > 0) {
|
2026-05-08 11:44:29 +02:00
|
|
|
try {
|
|
|
|
|
const r0 = sel.getRangeAt(0)
|
2026-05-08 11:51:28 +02:00
|
|
|
caretInside = editorEl.contains(r0.commonAncestorContainer)
|
2026-05-08 11:44:29 +02:00
|
|
|
} catch {
|
2026-05-08 11:51:28 +02:00
|
|
|
caretInside = false
|
2026-05-08 11:44:29 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-08 11:51:28 +02:00
|
|
|
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)) {
|
2026-05-08 11:44:29 +02:00
|
|
|
range = document.createRange()
|
|
|
|
|
range.selectNodeContents(editorEl)
|
|
|
|
|
range.collapse(false)
|
2026-05-08 11:51:28 +02:00
|
|
|
sel.removeAllRanges()
|
|
|
|
|
sel.addRange(range)
|
|
|
|
|
}
|
|
|
|
|
try {
|
2026-05-08 12:00:02 +02:00
|
|
|
const tpl = document.createElement('template')
|
|
|
|
|
tpl.innerHTML = html
|
|
|
|
|
const span = tpl.content.firstChild
|
|
|
|
|
if (!span) return false
|
2026-05-08 11:51:28 +02:00
|
|
|
range.deleteContents()
|
|
|
|
|
range.insertNode(span)
|
|
|
|
|
range.setStartAfter(span)
|
|
|
|
|
range.collapse(true)
|
|
|
|
|
sel.removeAllRanges()
|
|
|
|
|
sel.addRange(range)
|
|
|
|
|
return true
|
|
|
|
|
} catch {
|
|
|
|
|
return false
|
2026-05-08 11:44:29 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 14:48:46 +02:00
|
|
|
/**
|
2026-05-08 12:00:02 +02:00
|
|
|
* Leichter WYSIWYG (contenteditable).
|
|
|
|
|
* @param {{ inlineExerciseId?: number|null, onExerciseMediaListChanged?: () => Promise<void> }} [extra]
|
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',
|
2026-05-08 12:00:02 +02:00
|
|
|
inlineExerciseId = null,
|
|
|
|
|
onExerciseMediaListChanged,
|
2026-05-08 11:44:29 +02:00
|
|
|
}) {
|
2026-04-27 14:48:46 +02:00
|
|
|
const ref = useRef(null)
|
2026-05-08 12:00:02 +02:00
|
|
|
const pendingRangeRef = useRef(null)
|
2026-04-27 14:48:46 +02:00
|
|
|
const [focused, setFocused] = useState(false)
|
2026-05-08 12:00:02 +02:00
|
|
|
const [fileModalOpen, setFileModalOpen] = useState(false)
|
|
|
|
|
const [embedModalOpen, setEmbedModalOpen] = useState(false)
|
2026-04-27 14:48:46 +02:00
|
|
|
|
|
|
|
|
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-05-08 12:00:02 +02:00
|
|
|
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],
|
|
|
|
|
)
|
|
|
|
|
|
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 12:00:02 +02:00
|
|
|
const showInlineToolbar = inlineExerciseId != null && Number(inlineExerciseId) > 0
|
2026-05-08 11:44:29 +02:00
|
|
|
|
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 12:00:02 +02:00
|
|
|
{showInlineToolbar ? (
|
|
|
|
|
<>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="rte-btn"
|
|
|
|
|
title="Datei aus Mediathek oder neu hochladen, in den Text einfügen"
|
|
|
|
|
onMouseDown={stashRangeAndOpen(() => setFileModalOpen(true))}
|
|
|
|
|
>
|
|
|
|
|
Medien im Text
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="rte-btn"
|
|
|
|
|
title="Embed-URL hinzufügen und im Text einfügen"
|
|
|
|
|
onMouseDown={stashRangeAndOpen(() => setEmbedModalOpen(true))}
|
|
|
|
|
>
|
|
|
|
|
Embed im Text
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
2026-05-08 11:44:29 +02:00
|
|
|
) : 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}
|
|
|
|
|
/>
|
2026-05-08 12:00:02 +02:00
|
|
|
|
|
|
|
|
{showInlineToolbar ? (
|
|
|
|
|
<ExerciseInlineFileMediaModal
|
|
|
|
|
open={fileModalOpen}
|
|
|
|
|
onClose={() => setFileModalOpen(false)}
|
|
|
|
|
exerciseId={Number(inlineExerciseId)}
|
|
|
|
|
onMediaListChanged={refreshExerciseMedia}
|
|
|
|
|
onInserted={(mid, sz) => finalizeInsertFromModal(mid, sz)}
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
|
|
|
|
{showInlineToolbar ? (
|
|
|
|
|
<ExerciseInlineEmbedModal
|
|
|
|
|
open={embedModalOpen}
|
|
|
|
|
onClose={() => setEmbedModalOpen(false)}
|
|
|
|
|
exerciseId={Number(inlineExerciseId)}
|
|
|
|
|
onMediaListChanged={refreshExerciseMedia}
|
|
|
|
|
onInserted={(mid, sz) => finalizeInsertFromModal(mid, sz)}
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
2026-04-27 14:48:46 +02:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|