add bullet and numbered list to comment in a ticket

This commit is contained in:
Yinyin Liu 2026-04-30 12:17:10 +02:00
parent 53f0363da6
commit 99ba1b947c
7 changed files with 216 additions and 39 deletions

View File

@ -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({
</Button>
</span>
</Tooltip>
<Tooltip title={intl.formatMessage({ id: 'commentFormatBullet', defaultMessage: 'Bullet list' })}>
<span>
<Button size="small" variant="outlined" sx={btnSx} onClick={handle('bullet')} disabled={disabled}>
<FormatListBulletedIcon fontSize="inherit" />
</Button>
</span>
</Tooltip>
<Tooltip title={intl.formatMessage({ id: 'commentFormatNumbered', defaultMessage: 'Numbered list' })}>
<span>
<Button size="small" variant="outlined" sx={btnSx} onClick={handle('numbered')} disabled={disabled}>
<FormatListNumberedIcon fontSize="inherit" />
</Button>
</span>
</Tooltip>
</Box>
);
}

View File

@ -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}
/>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
@ -400,6 +401,7 @@ function CommentThread({
})}
value={body}
onChange={handleBodyChange}
onKeyDown={(e) => handleListEnter(e, body, setBody)}
inputRef={commentInputRef}
/>
<Popper

View File

@ -1,7 +1,10 @@
import React from 'react';
import { Box, Typography } from '@mui/material';
export type FormatKind = 'bold' | 'h1' | 'h2';
export type FormatKind = 'bold' | 'h1' | 'h2' | 'bullet' | 'numbered';
const BULLET_RE = /^- /;
const NUMBERED_RE = /^\d+\.\s/;
function renderInline(text: string): React.ReactNode[] {
const parts = text.split(/\*\*(.+?)\*\*/g);
@ -10,41 +13,175 @@ function renderInline(text: string): React.ReactNode[] {
);
}
type ListBuf = { ordered: boolean; items: string[] };
export function renderCommentBody(body: string): JSX.Element {
const lines = body.split('\n');
return (
<Box sx={{ '& > *': { mb: 0.5 } }}>
{lines.map((line, idx) => {
if (line.startsWith('## ')) {
return (
<Typography
key={idx}
variant="h6"
sx={{ fontSize: '1.15rem', fontWeight: 700, mt: 1.25 }}
>
{renderInline(line.slice(3))}
</Typography>
);
}
if (line.startsWith('# ')) {
return (
<Typography
key={idx}
variant="h4"
sx={{ fontSize: '1.6rem', fontWeight: 700, mt: 2 }}
>
{renderInline(line.slice(2))}
</Typography>
);
}
return (
<Typography key={idx} variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{line ? renderInline(line) : ' '}
</Typography>
);
})}
</Box>
);
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) => (
<li key={i} style={{ marginBottom: 2 }}>
{renderInline(item)}
</li>
));
blocks.push(
listBuf.ordered ? (
<Box
component="ol"
key={key}
sx={{ pl: 3, my: 0.5, '& li': { typography: 'body2' } }}
>
{items}
</Box>
) : (
<Box
component="ul"
key={key}
sx={{ pl: 3, my: 0.5, '& li': { typography: 'body2' } }}
>
{items}
</Box>
)
);
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(
<Typography
key={idx}
variant="h6"
sx={{ fontSize: '1.15rem', fontWeight: 700, mt: 1.25 }}
>
{renderInline(line.slice(3))}
</Typography>
);
return;
}
if (line.startsWith('# ')) {
blocks.push(
<Typography
key={idx}
variant="h4"
sx={{ fontSize: '1.6rem', fontWeight: 700, mt: 2 }}
>
{renderInline(line.slice(2))}
</Typography>
);
return;
}
blocks.push(
<Typography key={idx} variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{line ? renderInline(line) : ' '}
</Typography>
);
});
flushList();
return <Box sx={{ '& > *': { mb: 0.5 } }}>{blocks}</Box>;
}
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);

View File

@ -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.",

View File

@ -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.",

View File

@ -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.",

View File

@ -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.",