From 99ba1b947c78a77d28877ea9c1fa3d44a4887a60 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Thu, 30 Apr 2026 12:17:10 +0200 Subject: [PATCH] add bullet and numbered list to comment in a ticket --- .../Tickets/CommentFormatToolbar.tsx | 16 ++ .../dashboards/Tickets/CommentThread.tsx | 4 +- .../dashboards/Tickets/commentMarkdown.tsx | 219 +++++++++++++++--- typescript/frontend-marios2/src/lang/de.json | 4 +- typescript/frontend-marios2/src/lang/en.json | 4 +- typescript/frontend-marios2/src/lang/fr.json | 4 +- typescript/frontend-marios2/src/lang/it.json | 4 +- 7 files changed, 216 insertions(+), 39 deletions(-) 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.",