add Markdown preview - resolves #774 default tip

Tue, 06 Jan 2026 20:08:54 +0100

author
Mike Becker <universe@uap-core.de>
date
Tue, 06 Jan 2026 20:08:54 +0100
changeset 409
109850e92e95
parent 408
179bda934121

add Markdown preview - resolves #774

src/main/kotlin/de/uapcore/lightpit/Constants.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/RequestMapping.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/logic/IssueLogic.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/servlet/IssuesServlet.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/viewmodel/Issues.kt file | annotate | diff | comparison | revisions
src/main/resources/localization/strings.properties file | annotate | diff | comparison | revisions
src/main/resources/localization/strings_de.properties file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/changelogs/changelog-de.jspf file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/changelogs/changelog.jspf file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/issue-form.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/issue-view.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jspf/markdown-editor.jspf file | annotate | diff | comparison | revisions
src/main/webapp/issue-editor.js file | annotate | diff | comparison | revisions
src/main/webapp/projects.css file | annotate | diff | comparison | revisions
--- a/src/main/kotlin/de/uapcore/lightpit/Constants.kt	Tue Jan 06 19:14:24 2026 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/Constants.kt	Tue Jan 06 20:08:54 2026 +0100
@@ -29,7 +29,7 @@
     /**
      * A data in yyyy-mm-dd format to identify the release.
      */
-    const val VERSION_DATE = "2025-10-09"
+    const val VERSION_DATE = "2026-01-06"
 
     /**
      * The path where the JSP files reside.
--- a/src/main/kotlin/de/uapcore/lightpit/RequestMapping.kt	Tue Jan 06 19:14:24 2026 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/RequestMapping.kt	Tue Jan 06 20:08:54 2026 +0100
@@ -221,6 +221,11 @@
         response.writer.write(json)
     }
 
+    fun renderText(text: String, type: String = "plain") {
+        response.contentType = "text/${type}; charset=utf-8"
+        response.writer.write(text)
+    }
+
     fun render(page: String? = null) {
         page?.let { contentPage = it }
         forward("site")
--- a/src/main/kotlin/de/uapcore/lightpit/logic/IssueLogic.kt	Tue Jan 06 19:14:24 2026 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/logic/IssueLogic.kt	Tue Jan 06 20:08:54 2026 +0100
@@ -1,5 +1,11 @@
 package de.uapcore.lightpit.logic
 
+import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension
+import com.vladsch.flexmark.ext.tables.TablesExtension
+import com.vladsch.flexmark.html.HtmlRenderer
+import com.vladsch.flexmark.parser.Parser
+import com.vladsch.flexmark.util.data.MutableDataSet
+import com.vladsch.flexmark.util.data.SharedDataKeys
 import de.uapcore.lightpit.HttpRequest
 import de.uapcore.lightpit.dao.DataAccessObject
 import de.uapcore.lightpit.dateOptValidator
@@ -283,4 +289,25 @@
 
     // always pretend that the operation was successful - if there was nothing to remove, it's okay
     http.renderCommit("${pathInfos.issuesHref}${issue.id}")
-}
\ No newline at end of file
+}
+
+fun formatMarkdown(text: String?): String {
+    if (text == null) return ""
+
+    val options = MutableDataSet()
+        .set(SharedDataKeys.EXTENSIONS, listOf(TablesExtension.create(), StrikethroughExtension.create()))
+    val parser = Parser.builder(options).build()
+    val renderer = HtmlRenderer.builder(
+        options
+            .set(HtmlRenderer.ESCAPE_HTML, true)
+    ).build()
+
+    val formatEmojis = { text: String ->
+        text
+            .replace("(/)", "&#9989;")
+            .replace("(x)", "&#10060;")
+            .replace("(!)", "&#9889;")
+    }
+
+    return renderer.render(parser.parse(formatEmojis(text)))
+}
--- a/src/main/kotlin/de/uapcore/lightpit/servlet/IssuesServlet.kt	Tue Jan 06 19:14:24 2026 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/servlet/IssuesServlet.kt	Tue Jan 06 20:08:54 2026 +0100
@@ -17,6 +17,7 @@
         get("/", this::issues)
 
         get("/search", this::issueSearch)
+        post("/preview-markdown", this::previewMarkdown)
         get("/%issue", this::issue)
         get("/%issue/edit", this::issueForm)
         get("/%issue/progress", this::issueProgress)
@@ -90,6 +91,10 @@
         )
     }
 
+    private fun previewMarkdown(http: HttpRequest, dao: DataAccessObject) {
+        http.renderText(formatMarkdown(http.body), "markdown")
+    }
+
     private fun issueForm(http: HttpRequest, dao: DataAccessObject) {
         val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue)
         if (issue == null) {
--- a/src/main/kotlin/de/uapcore/lightpit/viewmodel/Issues.kt	Tue Jan 06 19:14:24 2026 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/Issues.kt	Tue Jan 06 20:08:54 2026 +0100
@@ -25,17 +25,12 @@
 
 package de.uapcore.lightpit.viewmodel
 
-import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension
-import com.vladsch.flexmark.ext.tables.TablesExtension
-import com.vladsch.flexmark.html.HtmlRenderer
-import com.vladsch.flexmark.parser.Parser
-import com.vladsch.flexmark.util.data.MutableDataSet
-import com.vladsch.flexmark.util.data.SharedDataKeys
 import de.uapcore.lightpit.HttpRequest
 import de.uapcore.lightpit.OptionalPathInfo
 import de.uapcore.lightpit.dao.DataAccessObject
 import de.uapcore.lightpit.entities.*
 import de.uapcore.lightpit.logic.compareEtaTo
+import de.uapcore.lightpit.logic.formatMarkdown
 import de.uapcore.lightpit.types.*
 import java.sql.Timestamp
 import kotlin.math.roundToInt
@@ -132,19 +127,8 @@
     val commitLinks: List<CommitLink>
     val openedVariant: Variant? = ((pathInfos as? PathInfosFull)?.variantInfo as? OptionalPathInfo.Specific)?.elem
 
-    private val parser: Parser
-    private val renderer: HtmlRenderer
-
     init {
-        val options = MutableDataSet()
-            .set(SharedDataKeys.EXTENSIONS, listOf(TablesExtension.create(), StrikethroughExtension.create()))
-        parser = Parser.builder(options).build()
-        renderer = HtmlRenderer.builder(
-            options
-                .set(HtmlRenderer.ESCAPE_HTML, true)
-        ).build()
-
-        issue.description = formatMarkdown(issue.description ?: "")
+        issue.description = formatMarkdown(issue.description)
         for (comment in comments) {
             comment.commentFormatted = formatMarkdown(comment.comment)
         }
@@ -165,14 +149,6 @@
             append(hash)
             toString()
         }
-
-    private fun formatEmojis(text: String) = text
-        .replace("(/)", "&#9989;")
-        .replace("(x)", "&#10060;")
-        .replace("(!)", "&#9889;")
-
-    private fun formatMarkdown(text: String) =
-        renderer.render(parser.parse(formatEmojis(text)))
 }
 
 class IssueEditView(
--- a/src/main/resources/localization/strings.properties	Tue Jan 06 19:14:24 2026 +0100
+++ b/src/main/resources/localization/strings.properties	Tue Jan 06 20:08:54 2026 +0100
@@ -33,6 +33,7 @@
 button.component.create=New Component
 button.component.edit=Edit Component
 button.dismiss=Dismiss
+button.edit=Edit
 button.issue.all=All Issues
 button.issue.create.another=Create another Issue
 button.issue.create=New Issue
@@ -41,6 +42,7 @@
 button.issue.progress=Progress
 button.issue.resolve=Resolve
 button.okay=OK
+button.preview=Preview
 button.project.create=New Project
 button.project.edit=Edit Project
 button.remove=Remove
--- a/src/main/resources/localization/strings_de.properties	Tue Jan 06 19:14:24 2026 +0100
+++ b/src/main/resources/localization/strings_de.properties	Tue Jan 06 20:08:54 2026 +0100
@@ -33,6 +33,7 @@
 button.component.create=Neue Komponente
 button.component.edit=Komponente Bearbeiten
 button.dismiss=Nicht Jetzt
+button.edit=Bearbeiten
 button.issue.all=Alle Vorg\u00e4nge
 button.issue.create.another=Weiteren Vorgang erstellen
 button.issue.create=Neuer Vorgang
@@ -41,6 +42,7 @@
 button.issue.progress=In Arbeit
 button.issue.resolve=Erledigt
 button.okay=OK
+button.preview=Vorschau
 button.project.create=Neues Projekt
 button.project.edit=Projekt Bearbeiten
 button.remove=Entfernen
--- a/src/main/webapp/WEB-INF/changelogs/changelog-de.jspf	Tue Jan 06 19:14:24 2026 +0100
+++ b/src/main/webapp/WEB-INF/changelogs/changelog-de.jspf	Tue Jan 06 20:08:54 2026 +0100
@@ -34,6 +34,7 @@
     <li>Schaltflächen hinzugefügt, die schnelleren Zugang zu den Editoren für Versionen, Komponenten und Varianten bieten.</li>
     <li>Es können nun neue Vorgänge direkt mit einer Verknüpfung zu einem existierenden Vorgang erstellt werden.</li>
     <li>Neuen Filter "zeige nur nicht-blockierte" hinzugefügt.</li>
+    <li>Vorschau für Markdown hinzugefügt.</li>
     <li>Vorgänge können nun auch direkt über die Vorgangsnummer (anstatt Raute + Nummer) verlinkt werden.</li>
     <li>Die Vorschläge in den Suchfeldern für Vorgänge sind nun absteigend nach Vorgangsnummer sortiert.</li>
     <li>Die Standardkategorie für neue Vorgänge in veröffentlichten Versionen ist nun "Fehler" anstelle von "Feature".</li>
--- a/src/main/webapp/WEB-INF/changelogs/changelog.jspf	Tue Jan 06 19:14:24 2026 +0100
+++ b/src/main/webapp/WEB-INF/changelogs/changelog.jspf	Tue Jan 06 20:08:54 2026 +0100
@@ -34,6 +34,7 @@
     <li>Add buttons and hover-icons to quickly access the editor for versions, components, and variants.</li>
     <li>Add the possibility to create new related issues with one click.</li>
     <li>Add new filter "show only non-blocked".</li>
+    <li>Add markdown preview.</li>
     <li>Change that you can now relate issues by just submitting their number (instead of hash + number).</li>
     <li>Change that issues suggested by the search boxes are now sorted by ID in descending order.</li>
     <li>Change that the default category for new issues in released versions is Bug instead of Feature.</li>
--- a/src/main/webapp/WEB-INF/jsp/issue-form.jsp	Tue Jan 06 19:14:24 2026 +0100
+++ b/src/main/webapp/WEB-INF/jsp/issue-form.jsp	Tue Jan 06 20:08:54 2026 +0100
@@ -139,7 +139,11 @@
         <tr>
             <th class="vtop"><label for="issue-description"><fmt:message key="issue.description"/></label></th>
             <td>
-                <textarea id="issue-description" name="description" rows="10"><c:out value="${issue.description}"/></textarea>
+                <c:set var="mde_id" value="issue-description" />
+                <c:set var="mde_name" value="description" />
+                <c:set var="mde_rows" value="10"/>
+                <c:set var="mde_value" value="${issue.description}"/>
+                <%@include file="../jspf/markdown-editor.jspf" %>
             </td>
         </tr>
         <tr>
@@ -193,7 +197,12 @@
         <c:if test="${issue.id ge 0}">
         <tr>
             <th class="vtop"><label for="issue-comment"><fmt:message key="issue.comment"/></label> </th>
-            <td><textarea id="issue-comment" rows="3" name="comment"></textarea></td>
+            <td>
+                <c:set var="mde_id" value="issue-comment" />
+                <c:set var="mde_name" value="comment" />
+                <c:set var="mde_rows" value="3"/>
+                <%@include file="../jspf/markdown-editor.jspf" %>
+            </td>
         </tr>
         </c:if>
         </tbody>
--- a/src/main/webapp/WEB-INF/jsp/issue-view.jsp	Tue Jan 06 19:14:24 2026 +0100
+++ b/src/main/webapp/WEB-INF/jsp/issue-view.jsp	Tue Jan 06 20:08:54 2026 +0100
@@ -334,7 +334,12 @@
     <table class="formtable fullwidth">
         <tbody>
             <tr>
-                <td><textarea rows="3" name="comment" required></textarea></td>
+                <td>
+                    <c:set var="mde_id" value="new-comment" />
+                    <c:set var="mde_name" value="comment" />
+                    <c:set var="mde_rows" value="3"/>
+                    <%@include file="../jspf/markdown-editor.jspf" %>
+                </td>
             </tr>
         </tbody>
         <tfoot>
@@ -383,7 +388,11 @@
                     <tbody>
                     <tr>
                         <td>
-                            <textarea rows="5" name="comment" required><c:out value="${comment.comment}"/></textarea>
+                            <c:set var="mde_id" value="edit-comment-${comment.id}" />
+                            <c:set var="mde_name" value="comment" />
+                            <c:set var="mde_rows" value="5"/>
+                            <c:set var="mde_value" value="${comment.comment}" />
+                            <%@include file="../jspf/markdown-editor.jspf" %>
                         </td>
                     </tr>
                     </tbody>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/webapp/WEB-INF/jspf/markdown-editor.jspf	Tue Jan 06 20:08:54 2026 +0100
@@ -0,0 +1,22 @@
+<%--
+  mde_id: String
+  mde_name: String
+  mde_rows: String
+  mde_value: String
+--%>
+<div>
+    <div class="mde-toolbar">
+        <button type="button" id="${mde_id}-btn-preview"><fmt:message key="button.preview" /></button>
+        <button type="button" id="${mde_id}-btn-edit"><fmt:message key="button.edit" /></button>
+    </div>
+    <textarea id="${mde_id}" name="${mde_name}" rows="${mde_rows}"><c:out value="${mde_value}"/></textarea>
+    <div id="${mde_id}-preview" class="markdown-styled mde-preview"></div>
+    <script type="text/javascript">
+        initMarkdownEditor('${mde_id}');
+    </script>
+</div>
+
+<c:remove var="mde_id" />
+<c:remove var="mde_name" />
+<c:remove var="mde_rows" />
+<c:remove var="mde_value" />
--- a/src/main/webapp/issue-editor.js	Tue Jan 06 19:14:24 2026 +0100
+++ b/src/main/webapp/issue-editor.js	Tue Jan 06 20:08:54 2026 +0100
@@ -56,6 +56,44 @@
     }
 }
 
+function initMarkdownEditor(id) {
+    const btn_preview = document.getElementById(`${id}-btn-preview`);
+    const btn_edit = document.getElementById(`${id}-btn-edit`);
+    const textarea = document.getElementById(`${id}`);
+    const preview = document.getElementById(`${id}-preview`);
+    btn_preview.addEventListener('click', () => {
+        textarea.style.display = 'none';
+        preview.style.display = 'block';
+        textarea.dataset.original = textarea.value;
+        btn_preview.style.display = 'none';
+        btn_edit.style.display = 'inline-block';
+        const req = new XMLHttpRequest();
+        req.addEventListener("load", (evt) => {
+            if (evt.target.status === 200) {
+                preview.innerHTML = evt.target.responseText;
+            } else {
+                // revert the button click as fallback
+                setTimeout(() => btn_edit.click(), 100);
+            }
+        });
+        let url = baseHref + 'issues/preview-markdown';
+        req.open("POST", url);
+        req.setRequestHeader("Content-Type", "text/plain");
+        req.send(textarea.value);
+    });
+    btn_edit.addEventListener('click', () => {
+        textarea.readOnly = false;
+        textarea.value = textarea.dataset.original;
+        textarea.style.display = 'block';
+        preview.style.display = 'none';
+        textarea.focus();
+        btn_preview.style.display = 'inline-block';
+        btn_edit.style.display = 'none';
+    });
+    btn_edit.style.display = 'none';
+    preview.style.display = 'none';
+}
+
 window.addEventListener("load", () => {
     toggleVariantStatus();
     const sbox = document.getElementById('linkable-issues');
--- a/src/main/webapp/projects.css	Tue Jan 06 19:14:24 2026 +0100
+++ b/src/main/webapp/projects.css	Tue Jan 06 20:08:54 2026 +0100
@@ -174,6 +174,22 @@
     color: #556080;
 }
 
+div.mde-toolbar {
+    margin-bottom: .25em;
+}
+
+div.mde-toolbar button {
+    font-size: small;
+}
+
+div.mde-preview {
+    border-style: solid;
+    border-width: 2px;
+    border-color: silver;
+    padding: .25em;
+    border-radius: 8px;
+}
+
 span.eta-overdue {
     color: red;
 }

mercurial