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
- 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.
468 lines
13 KiB
JavaScript
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, '<')
|
|
}
|
|
|
|
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>
|
|
)
|
|
}
|