chore(release): bump version to 0.8.61 and update changelog
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 1s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 23s

- Updated application version to 0.8.61.
- Added changelog entry for version 0.8.61, detailing the new inline help feature for the "Bild/Video im Text" functionality, including a visible 📎-chip in the editor and prompts for failed insertions.
This commit is contained in:
Lars 2026-05-08 11:51:28 +02:00
parent cc51b0f08f
commit 979e328cef
3 changed files with 120 additions and 37 deletions

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.60"
APP_VERSION = "0.8.61"
BUILD_DATE = "2026-05-08"
DB_SCHEMA_VERSION = "20260508049"
@ -29,6 +29,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.61",
"date": "2026-05-08",
"changes": [
"RTE „Bild/Video im Text“: eingebaute Hilfe (Caret + insertHTML/Fallback); sichtbarer 📎-Chip im Editor; Hinweis bei fehlgeschlagener Einfügung/Prompt-ID",
],
},
{
"version": "0.8.60",
"date": "2026-05-08",

View File

@ -3895,6 +3895,26 @@ a.analysis-split__nav-item {
overflow-y: auto;
resize: vertical;
}
/* §11 Inline-Marker: im Editor sichtbar (DOM ist nur leeres span + ZWJ — sonst „passiert nichts“-Effekt) */
.rich-text-editor span.shinkan-inline-media {
display: inline-block;
vertical-align: baseline;
margin: 2px 4px;
padding: 2px 8px;
border-radius: 6px;
border: 1px dashed var(--accent);
background: var(--surface2);
font-size: 13px;
font-weight: 600;
color: var(--accent-dark);
line-height: 1.35;
cursor: default;
}
.rich-text-editor span.shinkan-inline-media::before {
content: '📎 #' attr(data-shinkan-exercise-media);
}
/* Listen im Editor (nicht nur in .rich-text-content) sonst „unsichtbare“ Bullets */
.rich-text-editor ul,
.rich-text-editor ol {

View File

@ -46,36 +46,72 @@ function normalText() {
}
function insertExerciseMediaPlaceholder(editorEl, mediaId) {
if (!editorEl || mediaId == null) return
if (!editorEl || mediaId == null) return false
const sid = parseInt(String(mediaId), 10)
if (!Number.isFinite(sid) || sid < 1) return
if (!Number.isFinite(sid) || sid < 1) return false
editorEl.focus()
const sel = window.getSelection()
if (!sel) return
let range = null
if (sel?.rangeCount) {
if (!sel) return false
let caretInside = false
if (sel.rangeCount > 0) {
try {
const r0 = sel.getRangeAt(0)
if (editorEl.contains(r0.commonAncestorContainer)) range = r0
caretInside = editorEl.contains(r0.commonAncestorContainer)
} catch {
/* ignore */
caretInside = false
}
}
if (!range) {
if (!caretInside) {
const anchor = document.createRange()
try {
anchor.selectNodeContents(editorEl)
anchor.collapse(false)
} catch {
return false
}
sel.removeAllRanges()
sel.addRange(anchor)
}
const html = `<span data-shinkan-exercise-media="${sid}" class="shinkan-inline-media">\u2060</span>`
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 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)
return true
} catch {
return 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)
}
/**
@ -144,30 +180,50 @@ export default function RichTextEditor({
e.preventDefault()
e.stopPropagation()
const el = ref.current
if (!el || !insertExerciseMediaSlots?.length) return
const slots = insertExerciseMediaSlots
if (!el || !slots?.length) return
let choice = ''
if (insertExerciseMediaSlots.length === 1) {
choice = String(insertExerciseMediaSlots[0].id)
if (slots.length === 1) {
choice = String(slots[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')}`,
'',
)
choice =
window.prompt(
`Medium-ID eingeben oder aus Liste:\n${slots
.slice(0, 30)
.map((s) => `${s.id}: ${s.label}`)
.join('\n')}`,
String(slots[0].id),
) ?? ''
}
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.')
if (!Number.isFinite(idParsed)) {
if (slots.length > 1) {
alert('Keine gültige Medium-ID angegeben.')
}
return
}
const saved = saveSelectionInside(el)
el.focus()
restoreSelection(saved)
insertExerciseMediaPlaceholder(el, idParsed)
sync()
if (!slots.some((s) => Number(s.id) === idParsed)) {
alert('Diese ID ist nicht in der Medienliste dieser Übung.')
return
}
const savedRange = saveSelectionInside(el)
queueMicrotask(() => {
const shell = ref.current
if (!shell) return
shell.focus()
restoreSelection(savedRange)
const ok = insertExerciseMediaPlaceholder(shell, idParsed)
if (!ok) {
alert(
'Einfügen ist fehlgeschlagen — bitte einmal ins Textfeld klicken (Cursor setzen), dann „Bild/Video im Text“ erneut.',
)
return
}
sync()
shell.focus()
})
}
return (