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 {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Reference in New Issue