import React, { useState, useRef, useCallback, useEffect } from "react"; import { MarkdownToolbar } from "./MarkdownToolbar"; import { FloatingToolbar } from "./FloatingToolbar"; import { NotePreview } from "./NotePreview"; import { ImageUpload } from "./ImageUpload"; import { FileUpload } from "./FileUpload"; import { useAppSelector, useAppDispatch } from "../../store/hooks"; import { useNotification } from "../../hooks/useNotification"; import { offlineNotesApi } from "../../api/offlineNotesApi"; import { aiApi } from "../../api/aiApi"; import { Icon } from "@iconify/react"; interface NoteEditorProps { onSave: () => void; } export const NoteEditor: React.FC = ({ onSave }) => { const [content, setContent] = useState(""); const [images, setImages] = useState([]); const [files, setFiles] = useState([]); const [isAiLoading, setIsAiLoading] = useState(false); const [showFloatingToolbar, setShowFloatingToolbar] = useState(false); const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: 0 }); const [hasSelection, setHasSelection] = useState(false); const [activeFormats, setActiveFormats] = useState({ bold: false, italic: false, strikethrough: false, }); const textareaRef = useRef(null); const isPreviewMode = useAppSelector((state) => state.ui.isPreviewMode); const { showNotification } = useNotification(); const aiEnabled = useAppSelector((state) => state.profile.aiEnabled); const dispatch = useAppDispatch(); const handleSave = async () => { if (!content.trim()) { showNotification("Введите текст заметки", "warning"); return; } try { const now = new Date(); const date = now.toLocaleDateString("ru-RU"); const time = now.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", }); const note = await offlineNotesApi.create({ content, date, time }); // Загружаем изображения if (images.length > 0) { await offlineNotesApi.uploadImages(note.id, images); } // Загружаем файлы if (files.length > 0) { await offlineNotesApi.uploadFiles(note.id, files); } showNotification("Заметка сохранена!", "success"); setContent(""); setImages([]); setFiles([]); onSave(); } catch (error) { console.error("Ошибка сохранения заметки:", error); showNotification("Ошибка сохранения заметки", "error"); } }; const handleAiImprove = async () => { if (!content.trim()) { showNotification("Введите текст для улучшения", "warning"); return; } setIsAiLoading(true); try { const improvedText = await aiApi.improveText(content); setContent(improvedText); showNotification("Текст улучшен!", "success"); } catch (error) { console.error("Ошибка улучшения текста:", error); showNotification("Ошибка улучшения текста", "error"); } finally { setIsAiLoading(false); } }; // Функция для определения активных форматов в выделенном тексте const getActiveFormats = useCallback(() => { const textarea = textareaRef.current; if (!textarea) { return { bold: false, italic: false, strikethrough: false }; } const start = textarea.selectionStart; const end = textarea.selectionEnd; // Если нет выделения, возвращаем все false if (start === end) { return { bold: false, italic: false, strikethrough: false }; } const selectedText = content.substring(start, end); // Инициализируем форматы const formats = { bold: false, italic: false, strikethrough: false, }; // Расширяем область проверки для захвата всех возможных тегов const checkRange = 10; // Проверяем до 10 символов перед и после const checkStart = Math.max(0, start - checkRange); const checkEnd = Math.min(content.length, end + checkRange); const contextText = content.substring(checkStart, checkEnd); const selectionInContext = start - checkStart; // Текст вокруг выделения const beforeContext = contextText.substring(0, selectionInContext); const afterContext = contextText.substring( selectionInContext + selectedText.length ); // Функция для подсчета символов в конце строки const countTrailingChars = (text: string, char: string) => { let count = 0; for (let i = text.length - 1; i >= 0; i--) { if (text[i] === char) count++; else break; } return count; }; // Функция для подсчета символов в начале строки const countLeadingChars = (text: string, char: string) => { let count = 0; for (let i = 0; i < text.length; i++) { if (text[i] === char) count++; else break; } return count; }; // Проверяем зачеркнутый (~~) - проверяем первым, так как он не пересекается с другими const strikethroughBefore = beforeContext.slice(-2); const strikethroughAfter = afterContext.slice(0, 2); const hasStrikethroughAround = strikethroughBefore === "~~" && strikethroughAfter === "~~"; const hasStrikethroughInside = selectedText.startsWith("~~") && selectedText.endsWith("~~") && selectedText.length >= 4; if (hasStrikethroughAround || hasStrikethroughInside) { formats.strikethrough = true; } // Проверяем звездочки для жирного и курсива // Подсчитываем количество звездочек в конце контекста перед выделением const trailingStars = countTrailingChars(beforeContext, "*"); const leadingStars = countLeadingChars(afterContext, "*"); const leadingStarsInSelection = countLeadingChars(selectedText, "*"); const trailingStarsInSelection = countTrailingChars(selectedText, "*"); // Проверяем жирный (**) // Жирный требует минимум 2 звездочки с каждой стороны const hasBoldStarsBefore = trailingStars >= 2; const hasBoldStarsAfter = leadingStars >= 2; const hasBoldStarsInSelection = leadingStarsInSelection >= 2 && trailingStarsInSelection >= 2; // Проверяем, что это действительно жирный if (hasBoldStarsBefore && hasBoldStarsAfter) { formats.bold = true; } else if (hasBoldStarsInSelection && selectedText.length >= 4) { formats.bold = true; } // Проверяем курсив (*) // Если есть 3+ звездочки с каждой стороны, это комбинация жирного и курсива // Если есть 1 звездочка с каждой стороны (и не 2), это курсив // Если есть 3+ звездочки, это жирный + курсив const exactItalicBefore = trailingStars === 1 || (trailingStars >= 3 && trailingStars % 2 === 1); const exactItalicAfter = leadingStars === 1 || (leadingStars >= 3 && leadingStars % 2 === 1); const exactItalicInSelection = (leadingStarsInSelection === 1 && trailingStarsInSelection === 1) || (leadingStarsInSelection >= 3 && trailingStarsInSelection >= 3 && leadingStarsInSelection % 2 === 1 && trailingStarsInSelection % 2 === 1); // Проверяем также внутри выделения if (exactItalicBefore && exactItalicAfter && !formats.bold) { // Если перед и после по 1 звездочке, это курсив formats.italic = true; } else if (trailingStars >= 3 && leadingStars >= 3) { // Если по 3+ звездочки, это комбинация жирного и курсива formats.italic = true; formats.bold = true; } else if (exactItalicInSelection && selectedText.length >= 2) { formats.italic = true; } else if ( leadingStarsInSelection === 1 && trailingStarsInSelection === 1 && selectedText.length >= 2 && !selectedText.startsWith("**") && !selectedText.endsWith("**") ) { formats.italic = true; } // Если определен жирный, но не определен курсив, и есть 3+ звездочки, значит курсив тоже есть if ( formats.bold && (trailingStars >= 3 || leadingStars >= 3 || leadingStarsInSelection >= 3 || trailingStarsInSelection >= 3) ) { formats.italic = true; } return formats; }, [content]); const insertMarkdown = useCallback( (before: string, after: string = "") => { const textarea = textareaRef.current; if (!textarea) return; const start = textarea.selectionStart; const end = textarea.selectionEnd; const selectedText = content.substring(start, end); // Определяем маркеры списков и цитат, которые обрабатываются построчно const listMarkers = ["- ", "1. ", "- [ ] ", "> "]; const isListMarker = listMarkers.includes(before); // Если это маркер списка и выделено несколько строк, обрабатываем построчно if (isListMarker && selectedText.includes("\n")) { const lines = selectedText.split("\n"); const beforeText = content.substring(0, start); const afterText = content.substring(end); // Определяем, есть ли уже такие маркеры на всех строках let allLinesHaveMarker = true; let hasAnyMarker = false; for (const line of lines) { const trimmedLine = line.trimStart(); if (before === "- ") { // Для маркированного списка проверяем различные варианты if (trimmedLine.match(/^[-*+]\s/)) { hasAnyMarker = true; } else { allLinesHaveMarker = false; } } else if (before === "1. ") { // Для нумерованного списка if (trimmedLine.match(/^\d+\.\s/)) { hasAnyMarker = true; } else { allLinesHaveMarker = false; } } else if (before === "- [ ] ") { // Для чекбокса if (trimmedLine.match(/^-\s+\[[ xX]\]\s/)) { hasAnyMarker = true; } else { allLinesHaveMarker = false; } } else if (before === "> ") { // Для цитаты if (trimmedLine.startsWith("> ")) { hasAnyMarker = true; } else { allLinesHaveMarker = false; } } } // Если все строки уже имеют маркер, удаляем их (переключение) // Если некоторые имеют, но не все - добавляем к тем, у которых нет const processedLines = lines.map((line, index) => { const trimmedLine = line.trimStart(); const leadingSpaces = line.substring(0, line.length - trimmedLine.length); let shouldToggle = false; if (before === "- ") { const match = trimmedLine.match(/^([-*+])\s/); if (match) { shouldToggle = true; } } else if (before === "1. ") { if (trimmedLine.match(/^\d+\.\s/)) { shouldToggle = true; } } else if (before === "- [ ] ") { if (trimmedLine.match(/^-\s+\[[ xX]\]\s/)) { shouldToggle = true; } } else if (before === "> ") { if (trimmedLine.startsWith("> ")) { shouldToggle = true; } } if (shouldToggle && allLinesHaveMarker) { // Удаляем маркер if (before === "- ") { const match = trimmedLine.match(/^([-*+])\s(.*)$/); return match ? leadingSpaces + match[2] : line; } else if (before === "1. ") { const match = trimmedLine.match(/^\d+\.\s(.*)$/); return match ? leadingSpaces + match[1] : line; } else if (before === "- [ ] ") { const match = trimmedLine.match(/^-\s+\[[ xX]\]\s(.*)$/); return match ? leadingSpaces + match[1] : line; } else if (before === "> ") { return trimmedLine.startsWith("> ") ? leadingSpaces + trimmedLine.substring(2) : line; } } else if (!shouldToggle || !allLinesHaveMarker) { // Добавляем маркер if (before === "1. ") { // Для нумерованного списка добавляем правильный номер const number = index + 1; return leadingSpaces + `${number}. ` + trimmedLine; } else { return leadingSpaces + before + trimmedLine; } } return line; }); const newSelectedText = processedLines.join("\n"); const newText = beforeText + newSelectedText + afterText; // Вычисляем новую позицию курсора const newStart = start; const newEnd = start + newSelectedText.length; setContent(newText); setTimeout(() => { textarea.focus(); textarea.setSelectionRange(newStart, newEnd); const formats = getActiveFormats(); setActiveFormats(formats); }, 0); return; } const tagLength = before.length; // Проверяем область вокруг выделения (расширяем для проверки тегов) const checkStart = Math.max(0, start - tagLength); const checkEnd = Math.min(content.length, end + tagLength); const contextText = content.substring(checkStart, checkEnd); const selectionInContext = start - checkStart; // Текст перед и после выделения в контексте const beforeContext = contextText.substring(0, selectionInContext); const afterContext = contextText.substring( selectionInContext + selectedText.length ); // Проверяем точное совпадение тегов const hasTagsBefore = beforeContext.endsWith(before); const hasTagsAfter = afterContext.startsWith(after); // Проверяем, есть ли теги внутри выделенного текста const startsWithTag = selectedText.startsWith(before); const endsWithTag = selectedText.endsWith(after); // Определяем, нужно ли удалять теги let shouldRemoveTags = false; if (before === "*" && after === "*") { // Для курсива нужно убедиться, что это не часть ** (жирного) const beforeBeforeChar = start > 1 ? content[start - 2] : ""; const afterAfterChar = end + 1 < content.length ? content[end + 1] : ""; // Проверяем, что перед * не стоит еще один *, и после * не стоит еще один * const isItalicBefore = hasTagsBefore && beforeBeforeChar !== "*"; const isItalicAfter = hasTagsAfter && afterAfterChar !== "*"; // Проверяем внутри выделения const hasItalicInside = startsWithTag && endsWithTag && selectedText.length >= 2 && !selectedText.startsWith("**") && !selectedText.endsWith("**"); shouldRemoveTags = (isItalicBefore && isItalicAfter) || hasItalicInside; } else if (before === "**" && after === "**") { // Для жирного проверяем точное совпадение ** shouldRemoveTags = (hasTagsBefore && hasTagsAfter) || (startsWithTag && endsWithTag && selectedText.length >= 4); } else if (before === "~~" && after === "~~") { // Для зачеркнутого проверяем точное совпадение ~~ shouldRemoveTags = (hasTagsBefore && hasTagsAfter) || (startsWithTag && endsWithTag && selectedText.length >= 4); } else { // Для других тегов (например, ||) shouldRemoveTags = (hasTagsBefore && hasTagsAfter) || (startsWithTag && endsWithTag && selectedText.length >= tagLength * 2); } let newText: string; let newStart: number; let newEnd: number; if (shouldRemoveTags) { // Удаляем существующие теги if (hasTagsBefore && hasTagsAfter) { // Теги находятся вокруг выделения (в контексте) newText = content.substring(0, start - tagLength) + selectedText + content.substring(end + tagLength); newStart = start - tagLength; newEnd = end - tagLength; } else { // Теги находятся внутри выделенного текста const innerText = selectedText.substring( tagLength, selectedText.length - tagLength ); newText = content.substring(0, start) + innerText + content.substring(end); newStart = start; newEnd = start + innerText.length; } } else { // Добавляем теги newText = content.substring(0, start) + before + selectedText + after + content.substring(end); newStart = start + before.length; newEnd = end + before.length; } setContent(newText); // Восстанавливаем фокус и выделение, затем обновляем активные форматы setTimeout(() => { textarea.focus(); textarea.setSelectionRange(newStart, newEnd); // Обновляем активные форматы после применения форматирования const formats = getActiveFormats(); setActiveFormats(formats); }, 0); }, [content, getActiveFormats] ); const insertColorMarkdown = useCallback(() => { const colorDialog = document.createElement("input"); colorDialog.type = "color"; colorDialog.style.display = "none"; document.body.appendChild(colorDialog); colorDialog.addEventListener("change", function () { const selectedColor = this.value; const textarea = textareaRef.current; if (!textarea) return; const start = textarea.selectionStart; const end = textarea.selectionEnd; const selected = content.substring(start, end); const before = content.substring(0, start); const after = content.substring(end); let replacement; if (selected.trim() === "") { replacement = `Текст`; } else { replacement = `${selected}`; } const newText = before + replacement + after; setContent(newText); setTimeout(() => { textarea.focus(); const cursorPosition = start + replacement.length; textarea.setSelectionRange(cursorPosition, cursorPosition); }, 0); document.body.removeChild(this); }); colorDialog.addEventListener("cancel", function () { document.body.removeChild(this); }); colorDialog.click(); }, [content]); // Ctrl/Alt + Enter для сохранения и автопродолжение списков const handleKeyDown = (e: React.KeyboardEvent) => { if ((e.altKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); handleSave(); } else if (e.key === "Enter") { // Автоматическое продолжение списков const textarea = e.currentTarget; const start = textarea.selectionStart; const text = textarea.value; const lines = text.split("\n"); // Определяем текущую строку let currentLineIndex = 0; let currentLineStart = 0; let currentLine = ""; for (let i = 0; i < lines.length; i++) { const lineLength = lines[i].length; if (currentLineStart + lineLength >= start) { currentLineIndex = i; currentLine = lines[i]; break; } currentLineStart += lineLength + 1; // +1 для символа новой строки } // Проверяем, является ли текущая строка списком const listPatterns = [ /^(\s*)- \[ \] /, // Чекбокс (не отмечен): - [ ] /^(\s*)- \[x\] /i, // Чекбокс (отмечен): - [x] /^(\s*)- /, // Неупорядоченный список: - /^(\s*)\* /, // Неупорядоченный список: * /^(\s*)\+ /, // Неупорядоченный список: + /^(\s*)(\d+)\. /, // Упорядоченный список: 1. 2. 3. /^(\s*)(\w+)\. /, // Буквенный список: a. b. c. /^(\s*)1\. /, // Нумерованный список (начинается с 1.) ]; let listMatch = null; let listType = null; for (const pattern of listPatterns) { const match = currentLine.match(pattern); if (match) { listMatch = match; if (pattern === listPatterns[0] || pattern === listPatterns[1]) { listType = "checkbox"; } else if ( pattern === listPatterns[2] || pattern === listPatterns[3] || pattern === listPatterns[4] ) { listType = "unordered"; } else if (pattern === listPatterns[7]) { // Нумерованный список всегда начинается с 1. listType = "numbered"; } else { listType = "ordered"; } break; } } if (listMatch) { e.preventDefault(); const indent = listMatch[1] || ""; // Отступы перед маркером const marker = listMatch[0].slice(indent.length); // Маркер списка без отступов // Получаем текст после маркера const afterMarker = currentLine.slice(listMatch[0].length); if (afterMarker.trim() === "") { // Если строка пустая после маркера, выходим из списка const beforeCursor = text.substring(0, start); const afterCursor = text.substring(start); // Удаляем маркер и отступы текущей строки const newBefore = beforeCursor.replace( /\n\s*- \[ \] \s*$|\n\s*- \[x\] \s*$|\n\s*[-*+]\s*$|\n\s*\d+\.\s*$|\n\s*\w+\.\s*$/i, "\n" ); const newContent = newBefore + afterCursor; setContent(newContent); // Устанавливаем курсор после удаленного маркера setTimeout(() => { const newCursorPos = newBefore.length; textarea.setSelectionRange(newCursorPos, newCursorPos); }, 0); } else { // Продолжаем список const beforeCursor = text.substring(0, start); const afterCursor = text.substring(start); let newMarker = ""; if (listType === "checkbox") { // Для чекбоксов всегда создаем новый пустой чекбокс newMarker = indent + "- [ ] "; } else if (listType === "unordered") { newMarker = indent + marker; } else if (listType === "ordered") { // Для упорядоченных списков увеличиваем номер const number = parseInt(listMatch[2]); const nextNumber = number + 1; const numberStr = listMatch[2].replace( /\d+/, nextNumber.toString() ); newMarker = indent + numberStr + ". "; } else if (listType === "numbered") { // Для нумерованного списка всегда начинаем с 1. newMarker = indent + "1. "; } const newContent = beforeCursor + "\n" + newMarker + afterCursor; setContent(newContent); // Устанавливаем курсор после нового маркера setTimeout(() => { const newCursorPos = start + 1 + newMarker.length; textarea.setSelectionRange(newCursorPos, newCursorPos); }, 0); } } } }; // Функция для вычисления позиции курсора в textarea const getCursorPosition = useCallback(() => { const textarea = textareaRef.current; if (!textarea) return null; const start = textarea.selectionStart; const end = textarea.selectionEnd; const hasSelection = start !== end; // Используем середину выделения или позицию курсора для позиционирования const position = hasSelection ? Math.floor((start + end) / 2) : start; const text = textarea.value.substring(0, position); const lines = text.split("\n"); const lineNumber = lines.length - 1; const currentLineText = lines[lines.length - 1]; // Получаем размеры textarea const rect = textarea.getBoundingClientRect(); const styles = window.getComputedStyle(textarea); const lineHeight = parseInt(styles.lineHeight) || 20; const paddingTop = parseInt(styles.paddingTop) || 0; const paddingLeft = parseInt(styles.paddingLeft) || 0; const fontSize = parseInt(styles.fontSize) || 14; // Более точный расчет ширины символа // Создаем временный элемент для измерения const measureEl = document.createElement("span"); measureEl.style.position = "absolute"; measureEl.style.visibility = "hidden"; measureEl.style.whiteSpace = "pre"; measureEl.style.font = styles.font; measureEl.textContent = currentLineText; document.body.appendChild(measureEl); const textWidth = measureEl.offsetWidth; document.body.removeChild(measureEl); // Вычисляем позицию (середина выделения или позиция курсора) const top = rect.top + paddingTop + lineNumber * lineHeight + lineHeight / 2; const left = rect.left + paddingLeft + textWidth; return { top, left, hasSelection }; }, []); // Обработчик выделения текста const handleSelection = useCallback(() => { if (isPreviewMode) { setShowFloatingToolbar(false); return; } // Проверяем, есть ли текст в редакторе const hasText = content.trim().length > 0; const position = getCursorPosition(); if (position && hasText) { setToolbarPosition({ top: position.top, left: position.left }); setHasSelection(position.hasSelection); setShowFloatingToolbar(true); // Определяем активные форматы только если есть выделение if (position.hasSelection) { const formats = getActiveFormats(); setActiveFormats(formats); } else { setActiveFormats({ bold: false, italic: false, strikethrough: false }); } } else { setShowFloatingToolbar(false); setHasSelection(false); setActiveFormats({ bold: false, italic: false, strikethrough: false }); } }, [isPreviewMode, content, getCursorPosition, getActiveFormats]); // Отслеживание выделения текста useEffect(() => { const textarea = textareaRef.current; if (!textarea || isPreviewMode) return; const handleMouseUp = () => { setTimeout(handleSelection, 0); }; const handleMouseMove = (e: MouseEvent) => { if (e.buttons === 1) { // Если зажата левая кнопка мыши (выделение) setTimeout(handleSelection, 0); } }; const handleKeyUp = () => { setTimeout(handleSelection, 0); }; // Предотвращаем появление контекстного меню браузера при выделении текста // Но разрешаем его, если редактор пустой (чтобы можно было вставить текст) const handleContextMenu = (e: MouseEvent) => { const target = e.target as HTMLElement; // Проверяем, что событие происходит в textarea if (target === textarea || textarea.contains(target)) { const hasText = textarea.value.trim().length > 0; const hasSelection = textarea.selectionStart !== textarea.selectionEnd; // Блокируем браузерное меню только если есть текст И есть выделение if (hasText && hasSelection) { e.preventDefault(); e.stopPropagation(); } } }; textarea.addEventListener("mouseup", handleMouseUp); textarea.addEventListener("mousemove", handleMouseMove); textarea.addEventListener("keyup", handleKeyUp); textarea.addEventListener("contextmenu", handleContextMenu); document.addEventListener("selectionchange", handleSelection); document.addEventListener("contextmenu", handleContextMenu, true); return () => { textarea.removeEventListener("mouseup", handleMouseUp); textarea.removeEventListener("mousemove", handleMouseMove); textarea.removeEventListener("keyup", handleKeyUp); textarea.removeEventListener("contextmenu", handleContextMenu); document.removeEventListener("selectionchange", handleSelection); document.removeEventListener("contextmenu", handleContextMenu, true); }; }, [isPreviewMode, handleSelection]); // Скрываем toolbar при клике вне textarea и toolbar useEffect(() => { const handleClickOutside = (e: MouseEvent) => { const textarea = textareaRef.current; const target = e.target as Node; // Проверяем, не кликнули ли на toolbar или его кнопки const floatingToolbar = document.querySelector(".floating-toolbar"); if (floatingToolbar && floatingToolbar.contains(target)) { // Кликнули на toolbar - не скрываем его return; } if (textarea && !textarea.contains(target)) { // Кликнули вне textarea и toolbar - скрываем только если нет выделения setTimeout(() => { if (textarea.selectionStart === textarea.selectionEnd) { setShowFloatingToolbar(false); } }, 0); } }; document.addEventListener("mousedown", handleClickOutside); return () => { document.removeEventListener("mousedown", handleClickOutside); }; }, []); // Обновляем позицию toolbar при прокрутке useEffect(() => { if (!showFloatingToolbar) return; const handleScroll = () => { const position = getCursorPosition(); if (position) { setToolbarPosition({ top: position.top, left: position.left }); setHasSelection(position.hasSelection); // Обновляем активные форматы при прокрутке только если есть выделение if (position.hasSelection) { const formats = getActiveFormats(); setActiveFormats(formats); } } }; const textarea = textareaRef.current; if (textarea) { textarea.addEventListener("scroll", handleScroll); window.addEventListener("scroll", handleScroll, true); } return () => { if (textarea) { textarea.removeEventListener("scroll", handleScroll); } window.removeEventListener("scroll", handleScroll, true); }; }, [showFloatingToolbar, getCursorPosition, getActiveFormats]); // Авторасширение textarea React.useEffect(() => { const textarea = textareaRef.current; if (!textarea) return; const autoExpand = () => { textarea.style.height = "auto"; textarea.style.height = textarea.scrollHeight + "px"; }; textarea.addEventListener("input", autoExpand); autoExpand(); return () => { textarea.removeEventListener("input", autoExpand); }; }, [content]); const imageInputRef = useRef(null); const fileInputRef = useRef(null); const handleImageButtonClick = () => { imageInputRef.current?.click(); }; const handleFileButtonClick = () => { fileInputRef.current?.click(); }; const handleImageSelect = (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); const newImages = files.filter( (file) => file.type.startsWith("image/") && file.size <= 10 * 1024 * 1024 ); if (newImages.length + images.length > 10) { showNotification("Можно загрузить максимум 10 изображений", "warning"); return; } setImages([...images, ...newImages]); if (imageInputRef.current) { imageInputRef.current.value = ""; } }; const handleFileSelect = (e: React.ChangeEvent) => { const selectedFiles = Array.from(e.target.files || []); const allowedExtensions = /pdf|doc|docx|xls|xlsx|txt|zip|rar|7z/; const allowedMimes = [ "application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "text/plain", "application/zip", "application/x-zip-compressed", "application/x-rar-compressed", "application/x-7z-compressed", ]; const newFiles = selectedFiles.filter( (file) => (allowedMimes.includes(file.type) || allowedExtensions.test( file.name.split(".").pop()?.toLowerCase() || "" )) && file.size <= 50 * 1024 * 1024 ); setFiles([...files, ...newFiles]); if (fileInputRef.current) { fileInputRef.current.value = ""; } }; return (
{!isPreviewMode && ( <>