/
viktorphp
/
front-socket
Обзор
Документация
Войти
/
viktorphp
/
front-socket
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/features/diary/entryEditor/ui/FileViewerModal.tsx
458 строк
14 KB
Иванов Виктор Евгеньевич
[feature-diary]: поправить баги и верстку, добавить README.md
12 апр 2026, 20:01
12 апр 2026, 20:01
0d63759
Код
Авторство
О чём код?
'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; import CheckIcon from '@mui/icons-material/Check'; import CloseIcon from '@mui/icons-material/Close'; import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import SaveIcon from '@mui/icons-material/Save'; import { Box, Button, CircularProgress, Dialog, IconButton, Stack, TextField, Tooltip, Typography, } from '@mui/material'; import { useDiaryStore } from '@entities/diary'; import { diaryApi } from '@shared/api/rest'; import { EOcrStatus, IDiaryFile } from '@shared/types'; interface IFileViewerModalProps { file: IDiaryFile; open: boolean; onClose: () => void; /** Вызывается при нажатии «Удалить файл» (открывает диалог подтверждения в родителе) */ onDeleteFile: (fileId: string) => void; } /** MIME-типы, содержимое которых можно отобразить как текст */ const TEXT_MIME_TYPES = new Set([ 'text/plain', 'text/html', 'text/css', 'text/javascript', 'text/markdown', 'application/json', 'application/xml', ]); /** * Модальное окно для просмотра прикреплённого файла. * Левая панель — содержимое файла (изображение / PDF / текст). * Правая панель — распознанный OCR-текст (редактируемый). * Граница между панелями перетаскивается мышкой. */ export const FileViewerModal = ({ file, open, onClose, onDeleteFile, }: IFileViewerModalProps) => { const { updateFileOcrResult } = useDiaryStore((state) => state); // Presigned URL для отображения файла const [fileUrl, setFileUrl] = useState<string | null>(null); const [isLoadingUrl, setIsLoadingUrl] = useState(false); // Содержимое текстового файла (fetched по URL) const [textContent, setTextContent] = useState<string | null>(null); // OCR-текст (редактируемый) const [ocrText, setOcrText] = useState(file.ocrText ?? ''); const [isSavingOcr, setIsSavingOcr] = useState(false); const [isSavedOcr, setIsSavedOcr] = useState(false); // Ширина левой панели в процентах (от 20% до 80%) const [leftPct, setLeftPct] = useState(50); // Drag-состояние для ресайзера const isDragging = useRef(false); const containerRef = useRef<HTMLDivElement>(null); // Загружаем presigned URL при открытии модалки useEffect(() => { if (!open) return; setIsLoadingUrl(true); diaryApi .getFileUrl(file.id) .then(async (url) => { setFileUrl(url); // Для текстовых файлов дополнительно загружаем содержимое if (TEXT_MIME_TYPES.has(file.mimeType)) { const res = await fetch(url); const text = await res.text(); setTextContent(text); } }) .catch((err) => console.error('Ошибка получения URL файла:', err)) .finally(() => setIsLoadingUrl(false)); // Синхронизируем OCR-текст с актуальным состоянием файла setOcrText(file.ocrText ?? ''); }, [open, file.id, file.ocrText]); /** Сохранить отредактированный OCR-текст */ const handleSaveOcr = async () => { setIsSavingOcr(true); try { const updated = await diaryApi.updateOcrText(file.id, ocrText); updateFileOcrResult(file.id, { ocrStatus: updated.ocrStatus, ocrText: updated.ocrText, ocrError: updated.ocrError, }); setIsSavedOcr(true); setTimeout(() => setIsSavedOcr(false), 2000); } catch (error) { console.error('Ошибка сохранения OCR-текста:', error); } finally { setIsSavingOcr(false); } }; // --- Логика перетаскивания разделителя --- const handleDividerMouseDown = useCallback(() => { isDragging.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; }, []); const handleMouseMove = useCallback((e: MouseEvent) => { if (!isDragging.current || !containerRef.current) return; const rect = containerRef.current.getBoundingClientRect(); const offsetX = e.clientX - rect.left; const totalWidth = rect.width; const pct = Math.min(80, Math.max(20, (offsetX / totalWidth) * 100)); setLeftPct(pct); }, []); const handleMouseUp = useCallback(() => { if (!isDragging.current) return; isDragging.current = false; document.body.style.cursor = ''; document.body.style.userSelect = ''; }, []); // Подписываемся на события мыши на уровне документа useEffect(() => { document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, [handleMouseMove, handleMouseUp]); /** Рендер содержимого левой панели в зависимости от MIME-типа */ const renderFileContent = () => { if (isLoadingUrl) { return ( <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%', }} > <CircularProgress /> </Box> ); } if (!fileUrl) { return ( <Typography color="text.secondary" sx={{ p: 2 }}> Не удалось загрузить файл. </Typography> ); } if (file.mimeType.startsWith('image/')) { return ( <Box sx={{ overflow: 'auto', height: '100%', display: 'flex', alignItems: 'flex-start', p: 1, }} > {/* eslint-disable-next-line @next/next/no-img-element */} <img src={fileUrl} alt={file.originalName} style={{ maxWidth: '100%', height: 'auto', borderRadius: 3 }} /> </Box> ); } if (file.mimeType === 'application/pdf') { return ( <iframe src={fileUrl} title={file.originalName} style={{ width: '100%', height: '100%', border: 'none' }} /> ); } if (TEXT_MIME_TYPES.has(file.mimeType) && textContent !== null) { return ( <Box sx={{ overflow: 'auto', height: '100%', p: 1 }}> <pre style={{ margin: 0, whiteSpace: 'pre-wrap', fontSize: '0.85rem' }} > {textContent} </pre> </Box> ); } return ( <Box sx={{ p: 2 }}> <Typography color="text.secondary" gutterBottom> Предварительный просмотр недоступен для этого формата. </Typography> <Button href={fileUrl} target="_blank" rel="noreferrer" variant="outlined" size="small" > Скачать файл </Button> </Box> ); }; const hasOcr = file.ocrStatus === EOcrStatus.DONE || file.ocrStatus === EOcrStatus.PENDING; return ( <Dialog open={open} onClose={onClose} maxWidth={false} fullWidth PaperProps={{ sx: { width: '90vw', height: '85vh', maxWidth: '90vw', borderRadius: 4, }, }} > {/* Заголовок с именем файла и кнопками */} <Box sx={{ py: 1.5, px: 3, backgroundColor: '#e0e0e0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0, }} > <Typography variant="h6" fontWeight="bold" noWrap sx={{ maxWidth: 'calc(100% - 160px)' }} > {file.originalName} </Typography> <Stack direction="row" spacing={1}> <Tooltip title="Удалить файл"> <IconButton size="medium" color="error" onClick={() => { onClose(); onDeleteFile(file.id); }} > <DeleteOutlinedIcon fontSize="medium" /> </IconButton> </Tooltip> <IconButton size="medium" onClick={onClose}> <CloseIcon fontSize="medium" /> </IconButton> </Stack> </Box> {/* Тело: split-панель с ресайзером */} <Box ref={containerRef} sx={{ display: 'flex', flex: 1, overflow: 'hidden', px: 2, pb: 2, backgroundColor: '#e0e0e0', height: 'calc(100% - 56px)', }} > {/* Левая панель — содержимое файла */} <Box sx={{ width: `${leftPct}%`, overflow: 'hidden', display: 'flex', flexDirection: 'column', backgroundColor: 'background.paper', borderRadius: 2, }} > <Typography variant="body1" fontWeight="medium" color="text.secondary" sx={{ px: 2, minHeight: 44, display: 'flex', alignItems: 'center', borderBottom: '1px solid', borderColor: 'divider', }} > Файл </Typography> <Box sx={{ flex: 1, overflow: 'hidden', border: '1px solid rgba(0,0,0,0.23)', borderRadius: 1, m: 1, p: 1.5, }} > {renderFileContent()} </Box> </Box> {/* Разделитель (draggable) */} <Box onMouseDown={handleDividerMouseDown} sx={{ width: 6, flexShrink: 0, cursor: 'col-resize', bgcolor: '#e0e0e0', '&:hover': { bgcolor: 'primary.light' }, transition: 'background-color 0.15s', }} /> {/* Правая панель — OCR-текст */} <Box sx={{ width: `${100 - leftPct}%`, display: 'flex', flexDirection: 'column', overflow: 'hidden', backgroundColor: 'background.paper', borderRadius: 2, }} > <Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2, minHeight: 44, borderBottom: '1px solid', borderColor: 'divider', }} > <Typography variant="body1" fontWeight="medium" color="text.secondary" > Распознанный текст </Typography> {/* Кнопка сохранения OCR-текста — только если текст есть и был изменён */} {hasOcr && ( <Button size="medium" startIcon={ isSavingOcr ? ( <CircularProgress size={16} /> ) : isSavedOcr ? ( <CheckIcon /> ) : ( <SaveIcon /> ) } onClick={handleSaveOcr} disabled={isSavingOcr} color={isSavedOcr ? 'success' : 'primary'} sx={{ textTransform: 'none', fontSize: '0.9rem' }} > {isSavedOcr ? 'Сохранено' : 'Сохранить'} </Button> )} </Stack> <Box sx={{ flex: 1, overflow: 'hidden', p: 1.5 }}> {file.ocrStatus === EOcrStatus.PENDING ? ( <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, p: 1 }}> <CircularProgress size={16} /> <Typography variant="body2" color="text.secondary"> Идёт распознавание... </Typography> </Box> ) : file.ocrStatus === EOcrStatus.ERROR ? ( <Typography variant="body2" color="error" sx={{ p: 1 }}> Ошибка распознавания: {file.ocrError ?? 'неизвестная ошибка'} </Typography> ) : file.ocrStatus === EOcrStatus.DONE ? ( <TextField fullWidth multiline value={ocrText} onChange={(e) => setOcrText(e.target.value)} variant="outlined" placeholder="Распознанный текст..." sx={{ height: '100%', '& .MuiInputBase-root': { height: '100%', alignItems: 'flex-start', }, '& .MuiInputBase-input': { height: '100% !important', overflow: 'auto !important', }, }} /> ) : ( <Typography variant="body2" color="text.disabled" sx={{ p: 1 }}> Текст не распознан. Нажмите «Распознать текст» в редакторе. </Typography> )} </Box> </Box> </Box> </Dialog> ); };