shinkan-jinkendo/frontend/src/components/RichTextEditor.jsx

247 lines
6.8 KiB
React
Raw Normal View History

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 (
<div className="rich-text-editor-wrap">
<div className="rich-text-toolbar" role="toolbar" aria-label="Formatierung">
<button type="button" className="rte-btn" title="Fett" onMouseDown={run(() => exec('bold'))}>
<strong>B</strong>
</button>
<button type="button" className="rte-btn" title="Kursiv" onMouseDown={run(() => exec('italic'))}>
<em>I</em>
</button>
<button type="button" className="rte-btn" title="Unterstrichen" onMouseDown={run(() => exec('underline'))}>
U
</button>
<span className="rte-sep" />
<button type="button" className="rte-btn" title="Normaler Absatz" onMouseDown={run(normalText)}>
Normal
</button>
<button type="button" className="rte-btn" title="Zwischenüberschrift" onMouseDown={run(() => formatBlock('h3'))}>
Ü3
</button>
<span className="rte-sep" />
<button
type="button"
className="rte-btn"
title="Aufzählung (Mehrzeilen-Markierung möglich)"
onMouseDown={run(() => exec('insertUnorderedList'))}
>
• Liste
</button>
<button type="button" className="rte-btn" title="Nummerierte Liste" onMouseDown={run(() => exec('insertOrderedList'))}>
1. Liste
</button>
<span className="rte-sep" />
<button type="button" className="rte-btn" title="Link einfügen" onMouseDown={onLink}>
Link
</button>
{showMediaPick ? (
<button
type="button"
className="rte-btn"
title="Übungs-Medium inline einfügen (§11)"
onMouseDown={onInsertExerciseMediaClick}
>
Bild/Video im Text
</button>
) : null}
<button
type="button"
className="rte-btn"
title="Zeichenformatierung am Cursor entfernen"
onMouseDown={run(() => {
exec('removeFormat')
exec('unlink')
})}
>
␡
</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>
)
}