From 979e328cefbe7531257dc9137f5782853f9b3038 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 8 May 2026 11:51:28 +0200 Subject: [PATCH] chore(release): bump version to 0.8.61 and update changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- backend/version.py | 9 +- frontend/src/app.css | 20 ++++ frontend/src/components/RichTextEditor.jsx | 128 +++++++++++++++------ 3 files changed, 120 insertions(+), 37 deletions(-) diff --git a/backend/version.py b/backend/version.py index 76cce51..89e9e53 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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", diff --git a/frontend/src/app.css b/frontend/src/app.css index bcd4fad..31cb8e5 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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 { diff --git a/frontend/src/components/RichTextEditor.jsx b/frontend/src/components/RichTextEditor.jsx index bffb0b4..3999531 100644 --- a/frontend/src/components/RichTextEditor.jsx +++ b/frontend/src/components/RichTextEditor.jsx @@ -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 = `\u2060` + 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 (