diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentFormatToolbar.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentFormatToolbar.tsx
index 09e688f76..3a562eb96 100644
--- a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentFormatToolbar.tsx
+++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentFormatToolbar.tsx
@@ -1,6 +1,8 @@
import React from 'react';
import { Box, Button, Tooltip } from '@mui/material';
import FormatBoldIcon from '@mui/icons-material/FormatBold';
+import FormatListBulletedIcon from '@mui/icons-material/FormatListBulleted';
+import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered';
import { useIntl } from 'react-intl';
import { applyFormat, FormatKind } from './commentMarkdown';
@@ -48,6 +50,20 @@ function CommentFormatToolbar({
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx
index 80de4f9f9..12e0c141b 100644
--- a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx
+++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx
@@ -35,7 +35,7 @@ import axiosConfig from 'src/Resources/axiosConfig';
import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes';
import DocumentList from 'src/components/DocumentList';
import CommentFormatToolbar from './CommentFormatToolbar';
-import { renderCommentBody } from './commentMarkdown';
+import { renderCommentBody, handleListEnter } from './commentMarkdown';
interface CommentThreadProps {
ticketId: number;
@@ -341,6 +341,7 @@ function CommentThread({
minRows={2}
value={editBody}
onChange={(e) => setEditBody(e.target.value)}
+ onKeyDown={(e) => handleListEnter(e, editBody, setEditBody)}
inputRef={editInputRef}
/>
@@ -400,6 +401,7 @@ function CommentThread({
})}
value={body}
onChange={handleBodyChange}
+ onKeyDown={(e) => handleListEnter(e, body, setBody)}
inputRef={commentInputRef}
/>
*': { mb: 0.5 } }}>
- {lines.map((line, idx) => {
- if (line.startsWith('## ')) {
- return (
-
- {renderInline(line.slice(3))}
-
- );
- }
- if (line.startsWith('# ')) {
- return (
-
- {renderInline(line.slice(2))}
-
- );
- }
- return (
-
- {line ? renderInline(line) : ' '}
-
- );
- })}
-
- );
+ const blocks: JSX.Element[] = [];
+ let listBuf: ListBuf | null = null;
+
+ const flushList = () => {
+ if (!listBuf) return;
+ const key = `list-${blocks.length}`;
+ const items = listBuf.items.map((item, i) => (
+
+ {renderInline(item)}
+
+ ));
+ blocks.push(
+ listBuf.ordered ? (
+
+ {items}
+
+ ) : (
+
+ {items}
+
+ )
+ );
+ listBuf = null;
+ };
+
+ lines.forEach((line, idx) => {
+ if (BULLET_RE.test(line)) {
+ const item = line.replace(BULLET_RE, '');
+ if (listBuf && !listBuf.ordered) {
+ listBuf.items.push(item);
+ } else {
+ flushList();
+ listBuf = { ordered: false, items: [item] };
+ }
+ return;
+ }
+ if (NUMBERED_RE.test(line)) {
+ const item = line.replace(NUMBERED_RE, '');
+ if (listBuf && listBuf.ordered) {
+ listBuf.items.push(item);
+ } else {
+ flushList();
+ listBuf = { ordered: true, items: [item] };
+ }
+ return;
+ }
+ flushList();
+
+ if (line.startsWith('## ')) {
+ blocks.push(
+
+ {renderInline(line.slice(3))}
+
+ );
+ return;
+ }
+ if (line.startsWith('# ')) {
+ blocks.push(
+
+ {renderInline(line.slice(2))}
+
+ );
+ return;
+ }
+ blocks.push(
+
+ {line ? renderInline(line) : ' '}
+
+ );
+ });
+ flushList();
+
+ return *': { mb: 0.5 } }}>{blocks};
+}
+
+function getLineRange(value: string, start: number, end: number) {
+ const lineStart = value.lastIndexOf('\n', start - 1) + 1;
+ const tailIdx = end > start ? end - 1 : end;
+ const nlAfter = value.indexOf('\n', tailIdx);
+ const lineEnd = nlAfter === -1 ? value.length : nlAfter;
+ return { lineStart, lineEnd };
+}
+
+function toggleListLines(block: string, ordered: boolean): string {
+ const lines = block.split('\n');
+ const re = ordered ? NUMBERED_RE : BULLET_RE;
+ const allMatch = lines.every((l) => re.test(l));
+
+ if (allMatch) {
+ return lines.map((l) => l.replace(re, '')).join('\n');
+ }
+
+ let n = 1;
+ return lines
+ .map((l) => {
+ const stripped = l.replace(BULLET_RE, '').replace(NUMBERED_RE, '');
+ if (ordered) {
+ const out = `${n}. ${stripped}`;
+ n += 1;
+ return out;
+ }
+ return `- ${stripped}`;
+ })
+ .join('\n');
+}
+
+export function handleListEnter(
+ e: React.KeyboardEvent,
+ value: string,
+ onChange: (next: string) => void
+): void {
+ if (e.key !== 'Enter' || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
+ const el = e.target as HTMLTextAreaElement | HTMLInputElement;
+ if (!el || (el.tagName !== 'TEXTAREA' && el.tagName !== 'INPUT')) return;
+ const start = el.selectionStart ?? value.length;
+ const end = el.selectionEnd ?? value.length;
+ if (start !== end) return;
+
+ const lineStart = value.lastIndexOf('\n', start - 1) + 1;
+ const line = value.slice(lineStart, start);
+
+ const bulletMatch = line.match(/^- (.*)$/);
+ const numberedMatch = line.match(/^(\d+)\. (.*)$/);
+ if (!bulletMatch && !numberedMatch) return;
+
+ e.preventDefault();
+
+ const content = bulletMatch ? bulletMatch[1] : numberedMatch![2];
+ if (content.length === 0) {
+ const next = value.slice(0, lineStart) + value.slice(start);
+ onChange(next);
+ requestAnimationFrame(() => {
+ el.focus();
+ el.setSelectionRange(lineStart, lineStart);
+ });
+ return;
+ }
+
+ const prefix = bulletMatch
+ ? '- '
+ : `${parseInt(numberedMatch![1], 10) + 1}. `;
+ const insertion = `\n${prefix}`;
+ const next = value.slice(0, start) + insertion + value.slice(end);
+ onChange(next);
+ const caret = start + insertion.length;
+ requestAnimationFrame(() => {
+ el.focus();
+ el.setSelectionRange(caret, caret);
+ });
}
export function applyFormat(
@@ -69,6 +206,20 @@ export function applyFormat(
return;
}
+ if (kind === 'bullet' || kind === 'numbered') {
+ const { lineStart, lineEnd } = getLineRange(value, start, end);
+ const block = value.slice(lineStart, lineEnd);
+ const newBlock = toggleListLines(block, kind === 'numbered');
+ const next = value.slice(0, lineStart) + newBlock + value.slice(lineEnd);
+ onChange(next);
+ const caret = lineStart + newBlock.length;
+ requestAnimationFrame(() => {
+ el?.focus();
+ el?.setSelectionRange(caret, caret);
+ });
+ return;
+ }
+
const prefix = kind === 'h1' ? '# ' : '## ';
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const nlAfter = value.indexOf('\n', start);
diff --git a/typescript/frontend-marios2/src/lang/de.json b/typescript/frontend-marios2/src/lang/de.json
index cf681f159..704ff0f13 100644
--- a/typescript/frontend-marios2/src/lang/de.json
+++ b/typescript/frontend-marios2/src/lang/de.json
@@ -575,10 +575,12 @@
"commentEdited": "(bearbeitet {time})",
"deleteComment": "Kommentar löschen",
"deleteCommentConfirm": "Der Kommentar und alle Anhänge werden dauerhaft gelöscht. Fortfahren?",
- "commentMarkdownHint": "Markdown: **fett**, #, ##",
+ "commentMarkdownHint": "Markdown: **fett**, #, ##, -, 1.",
"commentFormatBold": "Fett",
"commentFormatH1": "Überschrift 1",
"commentFormatH2": "Überschrift 2",
+ "commentFormatBullet": "Aufzählung",
+ "commentFormatNumbered": "Nummerierte Liste",
"addComment": "Hinzufügen",
"timeline": "Zeitverlauf",
"noTimelineEvents": "Noch keine Ereignisse.",
diff --git a/typescript/frontend-marios2/src/lang/en.json b/typescript/frontend-marios2/src/lang/en.json
index 059853db3..a7393b452 100644
--- a/typescript/frontend-marios2/src/lang/en.json
+++ b/typescript/frontend-marios2/src/lang/en.json
@@ -323,10 +323,12 @@
"commentEdited": "(edited {time})",
"deleteComment": "Delete comment",
"deleteCommentConfirm": "This will permanently delete the comment and any attachments. Continue?",
- "commentMarkdownHint": "Markdown: **bold**, #, ##",
+ "commentMarkdownHint": "Markdown: **bold**, #, ##, -, 1.",
"commentFormatBold": "Bold",
"commentFormatH1": "Heading 1",
"commentFormatH2": "Heading 2",
+ "commentFormatBullet": "Bullet list",
+ "commentFormatNumbered": "Numbered list",
"addComment": "Add",
"timeline": "Timeline",
"noTimelineEvents": "No events yet.",
diff --git a/typescript/frontend-marios2/src/lang/fr.json b/typescript/frontend-marios2/src/lang/fr.json
index f4931b4a6..31d63d5ed 100644
--- a/typescript/frontend-marios2/src/lang/fr.json
+++ b/typescript/frontend-marios2/src/lang/fr.json
@@ -575,10 +575,12 @@
"commentEdited": "(modifié {time})",
"deleteComment": "Supprimer le commentaire",
"deleteCommentConfirm": "Le commentaire et toutes les pièces jointes seront définitivement supprimés. Continuer ?",
- "commentMarkdownHint": "Markdown : **gras**, #, ##",
+ "commentMarkdownHint": "Markdown : **gras**, #, ##, -, 1.",
"commentFormatBold": "Gras",
"commentFormatH1": "Titre 1",
"commentFormatH2": "Titre 2",
+ "commentFormatBullet": "Liste à puces",
+ "commentFormatNumbered": "Liste numérotée",
"addComment": "Ajouter",
"timeline": "Chronologie",
"noTimelineEvents": "Aucun événement pour le moment.",
diff --git a/typescript/frontend-marios2/src/lang/it.json b/typescript/frontend-marios2/src/lang/it.json
index 38fa25903..d2b4597f4 100644
--- a/typescript/frontend-marios2/src/lang/it.json
+++ b/typescript/frontend-marios2/src/lang/it.json
@@ -575,10 +575,12 @@
"commentEdited": "(modificato {time})",
"deleteComment": "Elimina commento",
"deleteCommentConfirm": "Il commento e tutti gli allegati saranno eliminati definitivamente. Continuare?",
- "commentMarkdownHint": "Markdown: **grassetto**, #, ##",
+ "commentMarkdownHint": "Markdown: **grassetto**, #, ##, -, 1.",
"commentFormatBold": "Grassetto",
"commentFormatH1": "Titolo 1",
"commentFormatH2": "Titolo 2",
+ "commentFormatBullet": "Elenco puntato",
+ "commentFormatNumbered": "Elenco numerato",
"addComment": "Aggiungi",
"timeline": "Cronologia",
"noTimelineEvents": "Nessun evento ancora.",