allow to paste screenshots in a ticket

This commit is contained in:
Yinyin Liu 2026-04-30 13:54:16 +02:00
parent 5586001b79
commit c189a077fb
10 changed files with 518 additions and 3 deletions

View File

@ -0,0 +1,128 @@
import React, { useEffect, useState } from 'react';
import { Box, Dialog, DialogContent, IconButton } from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import BrokenImageIcon from '@mui/icons-material/BrokenImage';
import axiosConfig from 'src/Resources/axiosConfig';
interface DocumentImageProps {
docId?: number;
src?: string;
alt?: string;
maxHeight?: number;
}
function DocumentImage({ docId, src, alt = '', maxHeight = 480 }: DocumentImageProps) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
if (src) {
setBlobUrl(src);
return;
}
if (!docId) return;
let cancelled = false;
let createdUrl: string | null = null;
axiosConfig
.get('/DownloadDocument', { params: { id: docId }, responseType: 'blob' })
.then((res) => {
if (cancelled) return;
const url = window.URL.createObjectURL(new Blob([res.data]));
createdUrl = url;
setBlobUrl(url);
})
.catch(() => {
if (!cancelled) setFailed(true);
});
return () => {
cancelled = true;
if (createdUrl) window.URL.revokeObjectURL(createdUrl);
};
}, [docId, src]);
if (failed) {
return (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
color: 'text.disabled',
fontSize: '0.85rem',
my: 0.5
}}
>
<BrokenImageIcon fontSize="small" />
{alt || 'Image unavailable'}
</Box>
);
}
if (!blobUrl) {
return (
<Box
sx={{
display: 'inline-block',
width: 120,
height: 80,
bgcolor: 'action.hover',
borderRadius: 1,
my: 0.5
}}
/>
);
}
return (
<>
<Box
component="img"
src={blobUrl}
alt={alt}
onClick={() => setOpen(true)}
sx={{
display: 'block',
maxWidth: '100%',
maxHeight,
borderRadius: 1,
border: '1px solid',
borderColor: 'divider',
cursor: 'zoom-in',
my: 0.5
}}
/>
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="lg">
<DialogContent sx={{ p: 0, position: 'relative', bgcolor: 'common.black' }}>
<IconButton
onClick={() => setOpen(false)}
sx={{
position: 'absolute',
top: 8,
right: 8,
bgcolor: 'rgba(0,0,0,0.5)',
color: 'common.white',
'&:hover': { bgcolor: 'rgba(0,0,0,0.7)' }
}}
aria-label="Close"
>
<CloseIcon />
</IconButton>
<Box
component="img"
src={blobUrl}
alt={alt}
sx={{
display: 'block',
maxWidth: '90vw',
maxHeight: '90vh',
objectFit: 'contain'
}}
/>
</DialogContent>
</Dialog>
</>
);
}
export default DocumentImage;

View File

@ -1,6 +1,7 @@
import React, { useContext, useRef, useState } from 'react';
import React, { useContext, useEffect, useRef, useState } from 'react';
import { UserContext } from 'src/contexts/userContext';
import {
Alert,
Avatar,
Box,
Button,
@ -21,6 +22,7 @@ import {
MenuList,
Paper,
Popper,
Snackbar,
TextField,
Tooltip,
Typography
@ -36,6 +38,7 @@ import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/Tick
import DocumentList from 'src/components/DocumentList';
import CommentFormatToolbar from './CommentFormatToolbar';
import { renderCommentBody, handleListEnter } from './commentMarkdown';
import { usePasteImage, PendingPaste } from 'src/hooks/usePasteImage';
interface CommentThreadProps {
ticketId: number;
@ -70,6 +73,37 @@ function CommentThread({
const commentInputRef = useRef<HTMLInputElement | null>(null);
const editInputRef = useRef<HTMLInputElement | null>(null);
const [pendingPastes, setPendingPastes] = useState<PendingPaste[]>([]);
const [pasteError, setPasteError] = useState<'tooLarge' | 'uploadFailed' | null>(null);
const pendingPastesRef = useRef<PendingPaste[]>([]);
pendingPastesRef.current = pendingPastes;
useEffect(() => {
return () => {
pendingPastesRef.current.forEach((p) => window.URL.revokeObjectURL(p.blobUrl));
};
}, []);
const handlePasteNewComment = usePasteImage({
mode: 'deferred',
textareaRef: commentInputRef,
value: body,
onChange: setBody,
onError: setPasteError,
onBufferedPaste: (p) => setPendingPastes((prev) => [...prev, p])
});
const handlePasteEditComment = usePasteImage({
mode: 'immediate',
textareaRef: editInputRef,
value: editBody,
onChange: setEditBody,
onError: setPasteError,
ticketId,
ticketCommentId: editingId ?? undefined
});
const MENTION_EXCLUDED_NAMES = ['inesco energy Master Admin'];
const mentionCandidates = mentionQuery === null
@ -192,11 +226,12 @@ function CommentThread({
};
const handleSubmit = async () => {
if (!body.trim() && selectedFiles.length === 0) return;
if (!body.trim() && selectedFiles.length === 0 && pendingPastes.length === 0) return;
setSubmitting(true);
try {
let commentId: number | undefined;
let finalBody = body;
if (body.trim()) {
const activeMentionedIds = mentionedIds.filter((uid) => {
const u = adminUsers.find((au) => au.id === uid);
@ -210,6 +245,42 @@ function CommentThread({
commentId = res.data?.id;
}
// Upload buffered pasted images, then rewrite blob: URLs to /DownloadDocument URLs
if (pendingPastes.length > 0 && commentId) {
setUploading(true);
let bodyChanged = false;
for (const paste of pendingPastes) {
const formData = new FormData();
formData.append('file', paste.blob);
try {
const res = await axiosConfig.post('/UploadDocument', formData, {
params: { scope: 0, ticketId, ticketCommentId: commentId },
headers: { 'Content-Type': 'multipart/form-data' }
});
const docId = res.data?.id;
if (docId) {
const realUrl = `/DownloadDocument?id=${docId}`;
if (finalBody.includes(paste.blobUrl)) {
finalBody = finalBody.split(paste.blobUrl).join(realUrl);
bodyChanged = true;
}
}
} catch (err: any) {
console.warn('[Paste] Upload failed:', err?.response?.data || err?.message);
setPasteError('uploadFailed');
}
window.URL.revokeObjectURL(paste.blobUrl);
}
if (bodyChanged) {
try {
await axiosConfig.post('/UpdateTicketComment', { id: commentId, body: finalBody });
} catch (err: any) {
console.warn('[Paste] Body rewrite failed:', err?.response?.data || err?.message);
}
}
setUploading(false);
}
if (selectedFiles.length > 0) {
setUploading(true);
for (const file of selectedFiles) {
@ -235,6 +306,7 @@ function CommentThread({
setMentionedIds([]);
setMentionQuery(null);
setSelectedFiles([]);
setPendingPastes([]);
setRefreshKey((k) => k + 1);
onCommentAdded();
} finally {
@ -342,6 +414,7 @@ function CommentThread({
value={editBody}
onChange={(e) => setEditBody(e.target.value)}
onKeyDown={(e) => handleListEnter(e, editBody, setEditBody)}
onPaste={handlePasteEditComment}
inputRef={editInputRef}
/>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
@ -402,6 +475,7 @@ function CommentThread({
value={body}
onChange={handleBodyChange}
onKeyDown={(e) => handleListEnter(e, body, setBody)}
onPaste={handlePasteNewComment}
inputRef={commentInputRef}
/>
<Popper
@ -498,6 +572,26 @@ function CommentThread({
</Button>
</DialogActions>
</Dialog>
<Snackbar
open={pasteError !== null}
autoHideDuration={4000}
onClose={() => setPasteError(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="error" onClose={() => setPasteError(null)}>
{pasteError === 'tooLarge' ? (
<FormattedMessage
id="pasteImageTooLarge"
defaultMessage="Pasted image exceeds 100 MB limit"
/>
) : (
<FormattedMessage
id="pasteImageUploadFailed"
defaultMessage="Failed to upload pasted image"
/>
)}
</Alert>
</Snackbar>
</Card>
);
}

View File

@ -15,6 +15,7 @@ import {
LinearProgress,
MenuItem,
Select,
Snackbar,
TextField,
Typography
} from '@mui/material';
@ -23,6 +24,7 @@ import { FormattedMessage, useIntl } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig';
import CommentFormatToolbar from './CommentFormatToolbar';
import { handleListEnter } from './commentMarkdown';
import { usePasteImage, PendingPaste } from 'src/hooks/usePasteImage';
import {
TicketPriority,
TicketCategory,
@ -94,6 +96,27 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [pendingPastes, setPendingPastes] = useState<PendingPaste[]>([]);
const [pasteError, setPasteError] = useState<'tooLarge' | 'uploadFailed' | null>(null);
const pendingPastesRef = useRef<PendingPaste[]>([]);
pendingPastesRef.current = pendingPastes;
useEffect(() => {
return () => {
pendingPastesRef.current.forEach((p) => window.URL.revokeObjectURL(p.blobUrl));
};
}, []);
const handlePasteDescription = usePasteImage({
mode: 'deferred',
textareaRef: descriptionRef,
value: description,
onChange: setDescription,
onError: setPasteError,
onBufferedPaste: (p) => setPendingPastes((prev) => [...prev, p])
});
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
const MAX_FILE_SIZE = 25 * 1024 * 1024;
@ -257,6 +280,8 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
setCustomSubCategory('');
setCustomCategory('');
setSelectedFiles([]);
pendingPastes.forEach((p) => window.URL.revokeObjectURL(p.blobUrl));
setPendingPastes([]);
setError('');
};
@ -287,7 +312,45 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
customCategory: isOtherCategory ? customCategory || null : null
});
const newTicketId = res.data?.id;
const createdTicket = res.data;
const newTicketId = createdTicket?.id;
// Upload buffered pasted images, then rewrite blob: URLs in description
if (pendingPastes.length > 0 && newTicketId) {
setUploading(true);
let finalDescription = description;
let descChanged = false;
for (const paste of pendingPastes) {
const formData = new FormData();
formData.append('file', paste.blob);
try {
const upRes = await axiosConfig.post('/UploadDocument', formData, {
params: { scope: 0, ticketId: newTicketId },
headers: { 'Content-Type': 'multipart/form-data' }
});
const docId = upRes.data?.id;
if (docId && finalDescription.includes(paste.blobUrl)) {
finalDescription = finalDescription.split(paste.blobUrl).join(`/DownloadDocument?id=${docId}`);
descChanged = true;
}
} catch (err: any) {
console.warn('[Paste] Upload failed:', err?.response?.data || err?.message);
setPasteError('uploadFailed');
}
window.URL.revokeObjectURL(paste.blobUrl);
}
if (descChanged && createdTicket) {
try {
await axiosConfig.put('/UpdateTicket', {
...createdTicket,
description: finalDescription
});
} catch (err: any) {
console.warn('[Paste] Description rewrite failed:', err?.response?.data || err?.message);
}
}
setUploading(false);
}
// Upload attached files if any
if (selectedFiles.length > 0 && newTicketId) {
@ -572,6 +635,7 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
value={description}
onChange={(e) => setDescription(e.target.value)}
onKeyDown={(e) => handleListEnter(e, description, setDescription)}
onPaste={handlePasteDescription}
inputRef={descriptionRef}
multiline
rows={4}
@ -630,6 +694,26 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
<FormattedMessage id="submit" defaultMessage="Submit" />
</Button>
</DialogActions>
<Snackbar
open={pasteError !== null}
autoHideDuration={4000}
onClose={() => setPasteError(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="error" onClose={() => setPasteError(null)}>
{pasteError === 'tooLarge' ? (
<FormattedMessage
id="pasteImageTooLarge"
defaultMessage="Pasted image exceeds 100 MB limit"
/>
) : (
<FormattedMessage
id="pasteImageUploadFailed"
defaultMessage="Failed to upload pasted image"
/>
)}
</Alert>
</Snackbar>
</Dialog>
);
}

View File

@ -22,6 +22,7 @@ import {
InputLabel,
MenuItem,
Select,
Snackbar,
TextField,
Typography
} from '@mui/material';
@ -52,6 +53,7 @@ import AiDiagnosisPanel from './AiDiagnosisPanel';
import CommentThread from './CommentThread';
import CommentFormatToolbar from './CommentFormatToolbar';
import { renderCommentBody, handleListEnter } from './commentMarkdown';
import { usePasteImage } from 'src/hooks/usePasteImage';
import TimelinePanel from './TimelinePanel';
import FileUploadButton from 'src/components/FileUploadButton';
import DocumentList from 'src/components/DocumentList';
@ -101,6 +103,19 @@ function TicketDetailPage() {
const rootCauseRef = useRef<HTMLInputElement | null>(null);
const solutionRef = useRef<HTMLInputElement | null>(null);
const descriptionRef = useRef<HTMLInputElement | null>(null);
const [pasteError, setPasteError] = useState<'tooLarge' | 'uploadFailed' | null>(null);
const handlePasteDescription = usePasteImage({
mode: 'immediate',
textareaRef: descriptionRef,
value: description,
onChange: (next) => {
setDescription(next);
setDescriptionSaved(false);
},
onError: setPasteError,
ticketId: detail?.ticket?.id
});
// Custom "Other" editing state
const [editCustomSub, setEditCustomSub] = useState('');
@ -447,6 +462,7 @@ function TicketDetailPage() {
setDescriptionSaved(false);
})
}
onPaste={handlePasteDescription}
inputRef={descriptionRef}
/>
<Box display="flex" justifyContent="flex-end" alignItems="center" gap={1}>
@ -1014,6 +1030,26 @@ function TicketDetailPage() {
</Dialog>
</Container>
<Snackbar
open={pasteError !== null}
autoHideDuration={4000}
onClose={() => setPasteError(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="error" onClose={() => setPasteError(null)}>
{pasteError === 'tooLarge' ? (
<FormattedMessage
id="pasteImageTooLarge"
defaultMessage="Pasted image exceeds 100 MB limit"
/>
) : (
<FormattedMessage
id="pasteImageUploadFailed"
defaultMessage="Failed to upload pasted image"
/>
)}
</Alert>
</Snackbar>
<Footer />
</div>
);

View File

@ -1,10 +1,20 @@
import React from 'react';
import { Box, Typography } from '@mui/material';
import DocumentImage from 'src/components/DocumentImage';
export type FormatKind = 'bold' | 'h1' | 'h2' | 'bullet' | 'numbered';
const BULLET_RE = /^- /;
const NUMBERED_RE = /^\d+\.\s/;
const IMAGE_RE = /^!\[([^\]]*)\]\((.+)\)\s*$/;
const DOC_URL_RE = /\/DownloadDocument\?(?:[^&]*&)*id=(\d+)/;
function parseImageUrl(url: string): { docId?: number; src?: string } | null {
const docMatch = url.match(DOC_URL_RE);
if (docMatch) return { docId: parseInt(docMatch[1], 10) };
if (url.startsWith('blob:')) return { src: url };
return null;
}
function renderInline(text: string): React.ReactNode[] {
const parts = text.split(/\*\*(.+?)\*\*/g);
@ -51,6 +61,23 @@ export function renderCommentBody(body: string): JSX.Element {
};
lines.forEach((line, idx) => {
const imageMatch = line.match(IMAGE_RE);
if (imageMatch) {
const parsed = parseImageUrl(imageMatch[2]);
if (parsed) {
flushList();
blocks.push(
<DocumentImage
key={idx}
docId={parsed.docId}
src={parsed.src}
alt={imageMatch[1]}
/>
);
return;
}
}
if (BULLET_RE.test(line)) {
const item = line.replace(BULLET_RE, '');
if (listBuf && !listBuf.ordered) {

View File

@ -0,0 +1,138 @@
import { ClipboardEvent, RefObject } from 'react';
import axiosConfig from 'src/Resources/axiosConfig';
export interface PendingPaste {
placeholderId: string;
blob: File;
blobUrl: string;
}
interface ImmediateOptions {
mode: 'immediate';
ticketId?: number;
ticketCommentId?: number;
}
interface DeferredOptions {
mode: 'deferred';
onBufferedPaste: (paste: PendingPaste) => void;
}
type Options = (ImmediateOptions | DeferredOptions) & {
textareaRef: RefObject<HTMLInputElement | HTMLTextAreaElement | null>;
value: string;
onChange: (next: string) => void;
onError?: (kind: 'tooLarge' | 'uploadFailed') => void;
};
const MAX_BYTES = 100 * 1024 * 1024;
function extensionFor(type: string): string {
if (type === 'image/png') return 'png';
if (type === 'image/jpeg') return 'jpg';
if (type === 'image/gif') return 'gif';
if (type === 'image/webp') return 'webp';
return 'png';
}
function makeFilename(type: string): string {
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
return `pasted-${stamp}.${extensionFor(type)}`;
}
function spliceAtCursor(
el: HTMLInputElement | HTMLTextAreaElement | null,
value: string,
insertion: string
): { next: string; caret: number } {
const start = el?.selectionStart ?? value.length;
const end = el?.selectionEnd ?? value.length;
const before = value.slice(0, start);
const after = value.slice(end);
const needsLeadingNl = before.length > 0 && !before.endsWith('\n');
const needsTrailingNl = after.length > 0 && !after.startsWith('\n');
const wrapped =
(needsLeadingNl ? '\n' : '') + insertion + (needsTrailingNl ? '\n' : '');
const next = before + wrapped + after;
const caret = before.length + wrapped.length;
return { next, caret };
}
export function usePasteImage(opts: Options) {
const { textareaRef, value, onChange, onError } = opts;
return async (e: ClipboardEvent<HTMLDivElement | HTMLInputElement | HTMLTextAreaElement>) => {
const items = e.clipboardData?.items;
if (!items) return;
let imageFile: File | null = null;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file' && item.type.startsWith('image/')) {
const f = item.getAsFile();
if (f) {
imageFile = f;
break;
}
}
}
if (!imageFile) return;
e.preventDefault();
if (imageFile.size > MAX_BYTES) {
onError?.('tooLarge');
return;
}
const filename = makeFilename(imageFile.type);
const renamed = new File([imageFile], filename, { type: imageFile.type });
if (opts.mode === 'deferred') {
const blobUrl = window.URL.createObjectURL(renamed);
const placeholderId = `paste-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
opts.onBufferedPaste({ placeholderId, blob: renamed, blobUrl });
const insertion = `![${placeholderId}](${blobUrl})`;
const { next, caret } = spliceAtCursor(textareaRef.current, value, insertion);
onChange(next);
requestAnimationFrame(() => {
const el = textareaRef.current;
if (!el) return;
el.focus();
el.setSelectionRange(caret, caret);
});
return;
}
// Immediate mode: upload now and splice the real /DownloadDocument URL
try {
const formData = new FormData();
formData.append('file', renamed);
const res = await axiosConfig.post('/UploadDocument', formData, {
params: {
scope: 0,
ticketId: opts.ticketId,
ticketCommentId: opts.ticketCommentId
},
headers: { 'Content-Type': 'multipart/form-data' }
});
const docId = res.data?.id;
if (!docId) {
onError?.('uploadFailed');
return;
}
const insertion = `![](/DownloadDocument?id=${docId})`;
const { next, caret } = spliceAtCursor(textareaRef.current, value, insertion);
onChange(next);
requestAnimationFrame(() => {
const el = textareaRef.current;
if (!el) return;
el.focus();
el.setSelectionRange(caret, caret);
});
} catch (err: any) {
console.warn('[Paste] Upload failed:', err?.response?.data || err?.message);
onError?.('uploadFailed');
}
};
}

View File

@ -581,6 +581,8 @@
"commentFormatH2": "Überschrift 2",
"commentFormatBullet": "Aufzählung",
"commentFormatNumbered": "Nummerierte Liste",
"pasteImageTooLarge": "Eingefügtes Bild überschreitet das Limit von 100 MB",
"pasteImageUploadFailed": "Eingefügtes Bild konnte nicht hochgeladen werden",
"addComment": "Hinzufügen",
"timeline": "Zeitverlauf",
"noTimelineEvents": "Noch keine Ereignisse.",

View File

@ -329,6 +329,8 @@
"commentFormatH2": "Heading 2",
"commentFormatBullet": "Bullet list",
"commentFormatNumbered": "Numbered list",
"pasteImageTooLarge": "Pasted image exceeds 100 MB limit",
"pasteImageUploadFailed": "Failed to upload pasted image",
"addComment": "Add",
"timeline": "Timeline",
"noTimelineEvents": "No events yet.",

View File

@ -581,6 +581,8 @@
"commentFormatH2": "Titre 2",
"commentFormatBullet": "Liste à puces",
"commentFormatNumbered": "Liste numérotée",
"pasteImageTooLarge": "L'image collée dépasse la limite de 100 Mo",
"pasteImageUploadFailed": "Échec du téléversement de l'image collée",
"addComment": "Ajouter",
"timeline": "Chronologie",
"noTimelineEvents": "Aucun événement pour le moment.",

View File

@ -581,6 +581,8 @@
"commentFormatH2": "Titolo 2",
"commentFormatBullet": "Elenco puntato",
"commentFormatNumbered": "Elenco numerato",
"pasteImageTooLarge": "L'immagine incollata supera il limite di 100 MB",
"pasteImageUploadFailed": "Caricamento dell'immagine incollata non riuscito",
"addComment": "Aggiungi",
"timeline": "Cronologia",
"noTimelineEvents": "Nessun evento ancora.",