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

329 lines
9.2 KiB
React
Raw Normal View History

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 `<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
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<void> }} [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 (
<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>
{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>
</>
) : 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}
/>
{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}
</div>
)
}