shinkan-jinkendo/frontend/src/components/RichTextEditor.jsx
Lars 5cf775c920
All checks were successful
Deploy Development / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 23s
Test Suite / pytest-backend (pull_request) Successful in 23s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 7s
Test Suite / playwright-tests (pull_request) Successful in 23s
feat(exercises): bump version to 0.8.64 and enhance media handling
- Incremented application version to 0.8.64 and updated changelog with new features.
- Improved media handling in the Rich Text Editor with auto-scrolling during drag-and-drop.
- Added new CSS styles for video thumbnails and enhanced layout for media items.
- Removed deprecated `ExerciseAttachmentMediaStrip` from the ExerciseFullContent component.
- Updated ExerciseFormPage to manage form dirty state and prevent data loss on navigation.
2026-05-08 12:35:28 +02:00

468 lines
13 KiB
JavaScript

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(/</g, '&lt;')
}
function buildInlineExerciseMediaHtml(mediaId, displaySize = 'medium', caption = '') {
const sid = parseInt(String(mediaId), 10)
if (!Number.isFinite(sid) || sid < 1) return null
const sz = ['small', 'medium', 'full'].includes(displaySize) ? displaySize : 'medium'
const cap = sanitizeInlineMediaCaption(caption)
let attrs = `data-shinkan-exercise-media="${sid}" data-shinkan-exercise-media-size="${sz}" class="shinkan-inline-media"`
if (cap) attrs += ` data-shinkan-exercise-media-caption="${escapeHtmlAttr(cap)}"`
return `<span ${attrs}>\u2060</span>`
}
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 (
<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}
onKeyDown={onEditorKeyDown}
onDragEnter={onEditorDragOver}
onDragOver={onEditorDragOver}
onDrop={onEditorDrop}
/>
{showInlineToolbar ? (
<ExerciseInlineFileMediaModal
open={fileModalOpen}
onClose={() => setFileModalOpen(false)}
exerciseId={Number(inlineExerciseId)}
linkedExerciseMedia={linkedExerciseMedia}
onMediaListChanged={refreshExerciseMedia}
onInserted={(mid, sz, cap) => finalizeInsertFromModal(mid, sz, cap)}
/>
) : null}
{showInlineToolbar ? (
<ExerciseInlineEmbedModal
open={embedModalOpen}
onClose={() => setEmbedModalOpen(false)}
exerciseId={Number(inlineExerciseId)}
onMediaListChanged={refreshExerciseMedia}
onInserted={(mid, sz, cap) => finalizeInsertFromModal(mid, sz, cap)}
/>
) : null}
</div>
)
}