add bullet and numbered list to comment in a ticket
This commit is contained in:
parent
53f0363da6
commit
99ba1b947c
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Reference in New Issue