shinkan-jinkendo/frontend/src/components/ExerciseRichTextBlock.jsx
Lars 311a106d93
All checks were successful
Deploy Development / deploy (push) Successful in 33s
Test Suite / pytest-backend (push) Successful in 24s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 27s
feat(exercises): enhance inline media functionality and update styles
- Updated inline media markup to include a new data attribute for media size.
- Enhanced the Rich Text Editor to support media size selection when inserting inline media.
- Improved CSS styles for inline media display, accommodating different sizes (small, medium, full).
- Bumped version to 0.8.62 and updated changelog to reflect these changes.
2026-05-08 12:00:02 +02:00

108 lines
3.9 KiB
JavaScript

import React, { useMemo } from 'react'
import { sanitizeExerciseRichDisplayHtml } from '../utils/exerciseRichTextSanitize'
import ExerciseMediaEmbed from './ExerciseMediaEmbed'
function isTrashHidden(m) {
return String(m?.asset_lifecycle_state || 'active').toLowerCase() === 'trash_hidden'
}
function buildVisibleMediaMap(mediaList) {
const map = new Map()
for (const m of mediaList || []) {
if (!m || m.id == null || isTrashHidden(m)) continue
map.set(Number(m.id), m)
}
return map
}
function domToReactNodes(node, exerciseId, mediaById, path) {
if (node.nodeType === Node.TEXT_NODE) {
const t = node.textContent
return t ? t : null
}
if (node.nodeType !== Node.ELEMENT_NODE) return null
const el = node
const tag = el.tagName.toLowerCase()
const key = path.join('.')
if (tag === 'span' && el.getAttribute('data-shinkan-exercise-media')) {
const raw = el.getAttribute('data-shinkan-exercise-media')
const mid = parseInt(raw, 10)
if (!Number.isFinite(mid) || mid < 1) {
return (
<span key={key} className="shinkan-inline-media-missing" style={{ color: 'var(--text3)', fontSize: '0.9em' }}>
[Ungültiger Medienverweis]
</span>
)
}
const media = mediaById.get(mid)
if (!media) {
return (
<span key={key} className="shinkan-inline-media-missing" style={{ color: 'var(--text3)', fontSize: '0.9em' }}>
[Medium nicht verfügbar]
</span>
)
}
const rawSize = (el.getAttribute('data-shinkan-exercise-media-size') || 'medium').toLowerCase().trim()
const layoutSize = rawSize === 'small' || rawSize === 'full' ? rawSize : 'medium'
const wrapClass =
layoutSize === 'small'
? 'shinkan-inline-media-wrap shinkan-inline-media-wrap--sm'
: layoutSize === 'full'
? 'shinkan-inline-media-wrap shinkan-inline-media-wrap--full'
: 'shinkan-inline-media-wrap shinkan-inline-media-wrap--md'
const lc = String(media.asset_lifecycle_state || 'active').toLowerCase()
return (
<span key={key} className={wrapClass} style={{ display: 'inline-block', verticalAlign: 'top' }}>
{lc === 'trash_soft' && (
<span style={{ fontSize: '0.75rem', color: 'var(--danger)', display: 'block', marginBottom: '4px' }}>
Dieses Medium ist im Papierkorb.
</span>
)}
<ExerciseMediaEmbed exerciseId={exerciseId} media={media} layoutSize={layoutSize} />
</span>
)
}
const children = []
const childNodes = Array.from(el.childNodes)
childNodes.forEach((ch, i) => {
const sub = domToReactNodes(ch, exerciseId, mediaById, [...path, String(i)])
if (sub != null && sub !== false) children.push(sub)
})
const props = { key }
if (tag === 'a' && el.getAttribute('href')) {
props.href = el.getAttribute('href')
props.target = '_blank'
props.rel = 'noreferrer'
}
return React.createElement(tag, props, children.length ? children : null)
}
/**
* Zentraler Anzeige-Pfad für Übungs-Rich-Text inkl. §11 Inline-Medien.
* @param {{ html?: string|null, exerciseId?: number|null, media?: object[]|null, className?: string }} props
*/
export default function ExerciseRichTextBlock({ html, exerciseId, media, className = '' }) {
const safe = useMemo(() => sanitizeExerciseRichDisplayHtml(html), [html])
const mediaById = useMemo(() => buildVisibleMediaMap(media), [media])
const body = useMemo(() => {
if (!safe.trim()) return null
const tpl = document.createElement('template')
tpl.innerHTML = safe
const nodes = []
Array.from(tpl.content.childNodes).forEach((ch, i) => {
const r = domToReactNodes(ch, exerciseId, mediaById, [String(i)])
if (r != null) nodes.push(r)
})
return nodes
}, [safe, exerciseId, mediaById])
if (!body || body.length === 0) return null
return <div className={`rich-text-content ${className}`.trim()}>{body}</div>
}