allow to paste screenshots in a ticket
This commit is contained in:
parent
5586001b79
commit
c189a077fb
|
|
@ -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;
|
||||||
|
|
@ -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 { UserContext } from 'src/contexts/userContext';
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
|
@ -21,6 +22,7 @@ import {
|
||||||
MenuList,
|
MenuList,
|
||||||
Paper,
|
Paper,
|
||||||
Popper,
|
Popper,
|
||||||
|
Snackbar,
|
||||||
TextField,
|
TextField,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography
|
Typography
|
||||||
|
|
@ -36,6 +38,7 @@ import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/Tick
|
||||||
import DocumentList from 'src/components/DocumentList';
|
import DocumentList from 'src/components/DocumentList';
|
||||||
import CommentFormatToolbar from './CommentFormatToolbar';
|
import CommentFormatToolbar from './CommentFormatToolbar';
|
||||||
import { renderCommentBody, handleListEnter } from './commentMarkdown';
|
import { renderCommentBody, handleListEnter } from './commentMarkdown';
|
||||||
|
import { usePasteImage, PendingPaste } from 'src/hooks/usePasteImage';
|
||||||
|
|
||||||
interface CommentThreadProps {
|
interface CommentThreadProps {
|
||||||
ticketId: number;
|
ticketId: number;
|
||||||
|
|
@ -70,6 +73,37 @@ function CommentThread({
|
||||||
const commentInputRef = useRef<HTMLInputElement | null>(null);
|
const commentInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const editInputRef = 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 MENTION_EXCLUDED_NAMES = ['inesco energy Master Admin'];
|
||||||
|
|
||||||
const mentionCandidates = mentionQuery === null
|
const mentionCandidates = mentionQuery === null
|
||||||
|
|
@ -192,11 +226,12 @@ function CommentThread({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!body.trim() && selectedFiles.length === 0) return;
|
if (!body.trim() && selectedFiles.length === 0 && pendingPastes.length === 0) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let commentId: number | undefined;
|
let commentId: number | undefined;
|
||||||
|
let finalBody = body;
|
||||||
if (body.trim()) {
|
if (body.trim()) {
|
||||||
const activeMentionedIds = mentionedIds.filter((uid) => {
|
const activeMentionedIds = mentionedIds.filter((uid) => {
|
||||||
const u = adminUsers.find((au) => au.id === uid);
|
const u = adminUsers.find((au) => au.id === uid);
|
||||||
|
|
@ -210,6 +245,42 @@ function CommentThread({
|
||||||
commentId = res.data?.id;
|
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) {
|
if (selectedFiles.length > 0) {
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
for (const file of selectedFiles) {
|
for (const file of selectedFiles) {
|
||||||
|
|
@ -235,6 +306,7 @@ function CommentThread({
|
||||||
setMentionedIds([]);
|
setMentionedIds([]);
|
||||||
setMentionQuery(null);
|
setMentionQuery(null);
|
||||||
setSelectedFiles([]);
|
setSelectedFiles([]);
|
||||||
|
setPendingPastes([]);
|
||||||
setRefreshKey((k) => k + 1);
|
setRefreshKey((k) => k + 1);
|
||||||
onCommentAdded();
|
onCommentAdded();
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -342,6 +414,7 @@ function CommentThread({
|
||||||
value={editBody}
|
value={editBody}
|
||||||
onChange={(e) => setEditBody(e.target.value)}
|
onChange={(e) => setEditBody(e.target.value)}
|
||||||
onKeyDown={(e) => handleListEnter(e, editBody, setEditBody)}
|
onKeyDown={(e) => handleListEnter(e, editBody, setEditBody)}
|
||||||
|
onPaste={handlePasteEditComment}
|
||||||
inputRef={editInputRef}
|
inputRef={editInputRef}
|
||||||
/>
|
/>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||||
|
|
@ -402,6 +475,7 @@ function CommentThread({
|
||||||
value={body}
|
value={body}
|
||||||
onChange={handleBodyChange}
|
onChange={handleBodyChange}
|
||||||
onKeyDown={(e) => handleListEnter(e, body, setBody)}
|
onKeyDown={(e) => handleListEnter(e, body, setBody)}
|
||||||
|
onPaste={handlePasteNewComment}
|
||||||
inputRef={commentInputRef}
|
inputRef={commentInputRef}
|
||||||
/>
|
/>
|
||||||
<Popper
|
<Popper
|
||||||
|
|
@ -498,6 +572,26 @@ function CommentThread({
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</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>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
LinearProgress,
|
LinearProgress,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Select,
|
Select,
|
||||||
|
Snackbar,
|
||||||
TextField,
|
TextField,
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
|
@ -23,6 +24,7 @@ import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
import axiosConfig from 'src/Resources/axiosConfig';
|
import axiosConfig from 'src/Resources/axiosConfig';
|
||||||
import CommentFormatToolbar from './CommentFormatToolbar';
|
import CommentFormatToolbar from './CommentFormatToolbar';
|
||||||
import { handleListEnter } from './commentMarkdown';
|
import { handleListEnter } from './commentMarkdown';
|
||||||
|
import { usePasteImage, PendingPaste } from 'src/hooks/usePasteImage';
|
||||||
import {
|
import {
|
||||||
TicketPriority,
|
TicketPriority,
|
||||||
TicketCategory,
|
TicketCategory,
|
||||||
|
|
@ -94,6 +96,27 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
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 ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
|
||||||
const MAX_FILE_SIZE = 25 * 1024 * 1024;
|
const MAX_FILE_SIZE = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
|
@ -257,6 +280,8 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
||||||
setCustomSubCategory('');
|
setCustomSubCategory('');
|
||||||
setCustomCategory('');
|
setCustomCategory('');
|
||||||
setSelectedFiles([]);
|
setSelectedFiles([]);
|
||||||
|
pendingPastes.forEach((p) => window.URL.revokeObjectURL(p.blobUrl));
|
||||||
|
setPendingPastes([]);
|
||||||
setError('');
|
setError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -287,7 +312,45 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
||||||
customCategory: isOtherCategory ? customCategory || null : null
|
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
|
// Upload attached files if any
|
||||||
if (selectedFiles.length > 0 && newTicketId) {
|
if (selectedFiles.length > 0 && newTicketId) {
|
||||||
|
|
@ -572,6 +635,7 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
onKeyDown={(e) => handleListEnter(e, description, setDescription)}
|
onKeyDown={(e) => handleListEnter(e, description, setDescription)}
|
||||||
|
onPaste={handlePasteDescription}
|
||||||
inputRef={descriptionRef}
|
inputRef={descriptionRef}
|
||||||
multiline
|
multiline
|
||||||
rows={4}
|
rows={4}
|
||||||
|
|
@ -630,6 +694,26 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
||||||
<FormattedMessage id="submit" defaultMessage="Submit" />
|
<FormattedMessage id="submit" defaultMessage="Submit" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</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>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
InputLabel,
|
InputLabel,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Select,
|
Select,
|
||||||
|
Snackbar,
|
||||||
TextField,
|
TextField,
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
|
@ -52,6 +53,7 @@ import AiDiagnosisPanel from './AiDiagnosisPanel';
|
||||||
import CommentThread from './CommentThread';
|
import CommentThread from './CommentThread';
|
||||||
import CommentFormatToolbar from './CommentFormatToolbar';
|
import CommentFormatToolbar from './CommentFormatToolbar';
|
||||||
import { renderCommentBody, handleListEnter } from './commentMarkdown';
|
import { renderCommentBody, handleListEnter } from './commentMarkdown';
|
||||||
|
import { usePasteImage } from 'src/hooks/usePasteImage';
|
||||||
import TimelinePanel from './TimelinePanel';
|
import TimelinePanel from './TimelinePanel';
|
||||||
import FileUploadButton from 'src/components/FileUploadButton';
|
import FileUploadButton from 'src/components/FileUploadButton';
|
||||||
import DocumentList from 'src/components/DocumentList';
|
import DocumentList from 'src/components/DocumentList';
|
||||||
|
|
@ -101,6 +103,19 @@ function TicketDetailPage() {
|
||||||
const rootCauseRef = useRef<HTMLInputElement | null>(null);
|
const rootCauseRef = useRef<HTMLInputElement | null>(null);
|
||||||
const solutionRef = useRef<HTMLInputElement | null>(null);
|
const solutionRef = useRef<HTMLInputElement | null>(null);
|
||||||
const descriptionRef = 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
|
// Custom "Other" editing state
|
||||||
const [editCustomSub, setEditCustomSub] = useState('');
|
const [editCustomSub, setEditCustomSub] = useState('');
|
||||||
|
|
@ -447,6 +462,7 @@ function TicketDetailPage() {
|
||||||
setDescriptionSaved(false);
|
setDescriptionSaved(false);
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
onPaste={handlePasteDescription}
|
||||||
inputRef={descriptionRef}
|
inputRef={descriptionRef}
|
||||||
/>
|
/>
|
||||||
<Box display="flex" justifyContent="flex-end" alignItems="center" gap={1}>
|
<Box display="flex" justifyContent="flex-end" alignItems="center" gap={1}>
|
||||||
|
|
@ -1014,6 +1030,26 @@ function TicketDetailPage() {
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
</Container>
|
</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 />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,20 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Typography } from '@mui/material';
|
import { Box, Typography } from '@mui/material';
|
||||||
|
import DocumentImage from 'src/components/DocumentImage';
|
||||||
|
|
||||||
export type FormatKind = 'bold' | 'h1' | 'h2' | 'bullet' | 'numbered';
|
export type FormatKind = 'bold' | 'h1' | 'h2' | 'bullet' | 'numbered';
|
||||||
|
|
||||||
const BULLET_RE = /^- /;
|
const BULLET_RE = /^- /;
|
||||||
const NUMBERED_RE = /^\d+\.\s/;
|
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[] {
|
function renderInline(text: string): React.ReactNode[] {
|
||||||
const parts = text.split(/\*\*(.+?)\*\*/g);
|
const parts = text.split(/\*\*(.+?)\*\*/g);
|
||||||
|
|
@ -51,6 +61,23 @@ export function renderCommentBody(body: string): JSX.Element {
|
||||||
};
|
};
|
||||||
|
|
||||||
lines.forEach((line, idx) => {
|
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)) {
|
if (BULLET_RE.test(line)) {
|
||||||
const item = line.replace(BULLET_RE, '');
|
const item = line.replace(BULLET_RE, '');
|
||||||
if (listBuf && !listBuf.ordered) {
|
if (listBuf && !listBuf.ordered) {
|
||||||
|
|
|
||||||
|
|
@ -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 = ``;
|
||||||
|
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 = ``;
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -581,6 +581,8 @@
|
||||||
"commentFormatH2": "Überschrift 2",
|
"commentFormatH2": "Überschrift 2",
|
||||||
"commentFormatBullet": "Aufzählung",
|
"commentFormatBullet": "Aufzählung",
|
||||||
"commentFormatNumbered": "Nummerierte Liste",
|
"commentFormatNumbered": "Nummerierte Liste",
|
||||||
|
"pasteImageTooLarge": "Eingefügtes Bild überschreitet das Limit von 100 MB",
|
||||||
|
"pasteImageUploadFailed": "Eingefügtes Bild konnte nicht hochgeladen werden",
|
||||||
"addComment": "Hinzufügen",
|
"addComment": "Hinzufügen",
|
||||||
"timeline": "Zeitverlauf",
|
"timeline": "Zeitverlauf",
|
||||||
"noTimelineEvents": "Noch keine Ereignisse.",
|
"noTimelineEvents": "Noch keine Ereignisse.",
|
||||||
|
|
|
||||||
|
|
@ -329,6 +329,8 @@
|
||||||
"commentFormatH2": "Heading 2",
|
"commentFormatH2": "Heading 2",
|
||||||
"commentFormatBullet": "Bullet list",
|
"commentFormatBullet": "Bullet list",
|
||||||
"commentFormatNumbered": "Numbered list",
|
"commentFormatNumbered": "Numbered list",
|
||||||
|
"pasteImageTooLarge": "Pasted image exceeds 100 MB limit",
|
||||||
|
"pasteImageUploadFailed": "Failed to upload pasted image",
|
||||||
"addComment": "Add",
|
"addComment": "Add",
|
||||||
"timeline": "Timeline",
|
"timeline": "Timeline",
|
||||||
"noTimelineEvents": "No events yet.",
|
"noTimelineEvents": "No events yet.",
|
||||||
|
|
|
||||||
|
|
@ -581,6 +581,8 @@
|
||||||
"commentFormatH2": "Titre 2",
|
"commentFormatH2": "Titre 2",
|
||||||
"commentFormatBullet": "Liste à puces",
|
"commentFormatBullet": "Liste à puces",
|
||||||
"commentFormatNumbered": "Liste numérotée",
|
"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",
|
"addComment": "Ajouter",
|
||||||
"timeline": "Chronologie",
|
"timeline": "Chronologie",
|
||||||
"noTimelineEvents": "Aucun événement pour le moment.",
|
"noTimelineEvents": "Aucun événement pour le moment.",
|
||||||
|
|
|
||||||
|
|
@ -581,6 +581,8 @@
|
||||||
"commentFormatH2": "Titolo 2",
|
"commentFormatH2": "Titolo 2",
|
||||||
"commentFormatBullet": "Elenco puntato",
|
"commentFormatBullet": "Elenco puntato",
|
||||||
"commentFormatNumbered": "Elenco numerato",
|
"commentFormatNumbered": "Elenco numerato",
|
||||||
|
"pasteImageTooLarge": "L'immagine incollata supera il limite di 100 MB",
|
||||||
|
"pasteImageUploadFailed": "Caricamento dell'immagine incollata non riuscito",
|
||||||
"addComment": "Aggiungi",
|
"addComment": "Aggiungi",
|
||||||
"timeline": "Cronologia",
|
"timeline": "Cronologia",
|
||||||
"noTimelineEvents": "Nessun evento ancora.",
|
"noTimelineEvents": "Nessun evento ancora.",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue