import React, { useState, useRef, useEffect, useCallback } from "react"; import { Icon } from "@iconify/react"; import { Note } from "../../types/note"; import { parseMarkdown, makeTagsClickable, highlightSearchText, } from "../../utils/markdown"; import { parseSQLiteUtc, formatLocalDateTime } from "../../utils/dateFormat"; import { useMarkdown } from "../../hooks/useMarkdown"; import { useAppSelector, useAppDispatch } from "../../store/hooks"; import { Modal } from "../common/Modal"; import { offlineNotesApi } from "../../api/offlineNotesApi"; import { useNotification } from "../../hooks/useNotification"; import { setSelectedTag } from "../../store/slices/notesSlice"; import { getImageUrl, getFileUrl } from "../../utils/filePaths"; import { MarkdownToolbar } from "./MarkdownToolbar"; import { FloatingToolbar } from "./FloatingToolbar"; import { NotePreview } from "./NotePreview"; import { ImageUpload } from "./ImageUpload"; import { FileUpload } from "./FileUpload"; import { aiApi } from "../../api/aiApi"; import { GenerateTagsModal } from "./GenerateTagsModal"; import { extractTags } from "../../utils/markdown"; interface NoteItemProps { note: Note; onDelete: (id: number | string) => void; onPin: (id: number | string) => void; onArchive: (id: number | string) => void; onReload: () => void; isSelected?: boolean; onSelect?: (id: number | string) => void; } export const NoteItem: React.FC = ({ note, onDelete: _onDelete, onPin, onArchive, onReload, isSelected = false, onSelect, }) => { const [isEditing, setIsEditing] = useState(false); const [editContent, setEditContent] = useState(note.content); const [showArchiveModal, setShowArchiveModal] = useState(false); const [editImages, setEditImages] = useState([]); const [editFiles, setEditFiles] = useState([]); const [deletedImageIds, setDeletedImageIds] = useState<(number | string)[]>([]); const [deletedFileIds, setDeletedFileIds] = useState<(number | string)[]>([]); 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 [localPreviewMode, setLocalPreviewMode] = useState(false); const [isExpanded, setIsExpanded] = useState(false); const [isLongNote, setIsLongNote] = useState(false); const [showTagsModal, setShowTagsModal] = useState(false); const [suggestedTags, setSuggestedTags] = useState([]); const [isGeneratingTags, setIsGeneratingTags] = useState(false); const [tagsGenerationError, setTagsGenerationError] = useState(false); const editTextareaRef = useRef(null); const imageInputRef = useRef(null); const fileInputRef = useRef(null); const textNoteRef = useRef(null); const shouldSetCursorToEndRef = useRef(false); const searchQuery = useAppSelector((state) => state.notes.searchQuery); const isPreviewMode = useAppSelector((state) => state.ui.isPreviewMode); const aiEnabled = useAppSelector((state) => state.profile.aiEnabled); const user = useAppSelector((state) => state.profile.user); const { showNotification } = useNotification(); const dispatch = useAppDispatch(); useMarkdown({ onNoteUpdate: onReload }); // Инициализируем обработчики спойлеров, внешних ссылок и чекбоксов // Проверяем, включена ли плавающая панель const floatingToolbarEnabled = user?.floating_toolbar_enabled !== undefined ? user.floating_toolbar_enabled === 1 : true; const handleEdit = () => { setIsEditing(true); setEditContent(note.content); setEditImages([]); setEditFiles([]); setDeletedImageIds([]); setDeletedFileIds([]); setShowFloatingToolbar(false); setActiveFormats({ bold: false, italic: false, strikethrough: false }); setLocalPreviewMode(false); shouldSetCursorToEndRef.current = true; // Устанавливаем флаг для установки курсора в конец }; const toggleLocalPreviewMode = () => { setLocalPreviewMode(!localPreviewMode); setShowFloatingToolbar(false); }; const handleSaveEdit = async () => { if (!editContent.trim()) { showNotification("Введите текст заметки", "warning"); return; } try { await offlineNotesApi.update(note.id, editContent); // Удаляем выбранные изображения for (const imageId of deletedImageIds) { await offlineNotesApi.deleteImage(note.id, imageId); } // Удаляем выбранные файлы for (const fileId of deletedFileIds) { await offlineNotesApi.deleteFile(note.id, fileId); } // Загружаем новые изображения if (editImages.length > 0) { await offlineNotesApi.uploadImages(note.id, editImages); } // Загружаем новые файлы if (editFiles.length > 0) { await offlineNotesApi.uploadFiles(note.id, editFiles); } showNotification("Заметка обновлена!", "success"); setIsEditing(false); setEditImages([]); setEditFiles([]); setDeletedImageIds([]); setDeletedFileIds([]); onReload(); } catch (error) { console.error("Ошибка обновления заметки:", error); showNotification("Ошибка обновления заметки", "error"); } }; const handleCancelEdit = () => { setIsEditing(false); setEditContent(note.content); setEditImages([]); setEditFiles([]); setDeletedImageIds([]); setDeletedFileIds([]); setShowFloatingToolbar(false); setActiveFormats({ bold: false, italic: false, strikethrough: false }); setLocalPreviewMode(false); }; const handleDeleteExistingImage = (imageId: number | string) => { setDeletedImageIds([...deletedImageIds, imageId]); }; const handleDeleteExistingFile = (fileId: number | string) => { setDeletedFileIds([...deletedFileIds, fileId]); }; const handleRestoreImage = (imageId: number | string) => { setDeletedImageIds(deletedImageIds.filter((id) => id !== imageId)); }; const handleRestoreFile = (fileId: number | string) => { setDeletedFileIds(deletedFileIds.filter((id) => id !== fileId)); }; const handleAiImprove = async () => { if (!editContent.trim()) { showNotification("Введите текст для улучшения", "warning"); return; } setIsAiLoading(true); try { const improvedText = await aiApi.improveText(editContent); setEditContent(improvedText); showNotification("Текст улучшен!", "success"); } catch (error) { console.error("Ошибка улучшения текста:", error); showNotification("Ошибка улучшения текста", "error"); } finally { setIsAiLoading(false); } }; const handleGenerateTags = async () => { if (!editContent.trim()) { showNotification("Введите текст для генерации тегов", "warning"); return; } setIsGeneratingTags(true); setTagsGenerationError(false); setSuggestedTags([]); setShowTagsModal(true); try { const tags = await aiApi.generateTags(editContent); if (tags && tags.length > 0) { setSuggestedTags(tags); setTagsGenerationError(false); } else { setTagsGenerationError(true); setShowTagsModal(false); showNotification("ИИ не смог предложить теги для этой заметки", "info"); } } catch (error: any) { console.error("Ошибка генерации тегов:", error); console.error("Детали ошибки:", error.response?.data); setTagsGenerationError(true); setShowTagsModal(false); const errorMessage = error.response?.data?.error || error.message || "Ошибка генерации тегов"; showNotification(errorMessage, "error"); } finally { setIsGeneratingTags(false); } }; const handleSelectTags = (tags: string[]) => { if (tags.length === 0) return; const existingTags = extractTags(editContent); const tagsToAdd = tags .filter((tag) => !existingTags.some((existing) => existing.toLowerCase() === tag.toLowerCase())) .map((tag) => `#${tag}`) .join(" "); if (tagsToAdd) { // Добавляем теги в конец заметки const newContent = editContent.trim() + (editContent.trim() ? "\n\n" : "") + tagsToAdd; setEditContent(newContent); showNotification(`Добавлено тегов: ${tags.length}`, "success"); } else { showNotification("Все предлагаемые теги уже есть в заметке", "info"); } }; // Функция для определения активных форматов в выделенном тексте const getActiveFormats = useCallback(() => { const textarea = editTextareaRef.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 = editContent.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(editContent.length, end + checkRange); const contextText = editContent.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; }, [editContent]); const insertMarkdown = useCallback( (before: string, after: string = "") => { const textarea = editTextareaRef.current; if (!textarea) return; const start = textarea.selectionStart; const end = textarea.selectionEnd; const selectedText = editContent.substring(start, end); const tagLength = before.length; // Проверяем область вокруг выделения (расширяем для проверки тегов) const checkStart = Math.max(0, start - tagLength); const checkEnd = Math.min(editContent.length, end + tagLength); const contextText = editContent.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 ? editContent[start - 2] : ""; const afterAfterChar = end + 1 < editContent.length ? editContent[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 = editContent.substring(0, start - tagLength) + selectedText + editContent.substring(end + tagLength); newStart = start - tagLength; newEnd = end - tagLength; } else { // Теги находятся внутри выделенного текста const innerText = selectedText.substring( tagLength, selectedText.length - tagLength ); newText = editContent.substring(0, start) + innerText + editContent.substring(end); newStart = start; newEnd = start + innerText.length; } } else { // Проверяем, является ли это форматированием списка или цитаты const isListFormatting = /^[-*+]\s|^\d+\.\s|^- \[ \]\s|^>\s/.test(before); const isMultiline = selectedText.includes("\n"); if (isListFormatting && isMultiline) { // Обрабатываем многострочное выделение для списков const lines = selectedText.split("\n"); let processedLines: string[] = []; let currentNumber = 1; let isFirstNonEmptyLine = true; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const trimmedLine = line.trim(); // Пропускаем пустые строки if (trimmedLine === "") { processedLines.push(line); continue; } // Определяем отступ текущей строки const indentMatch = line.match(/^(\s*)/); const indent = indentMatch ? indentMatch[1] : ""; // Обрабатываем в зависимости от типа маркера if (before.startsWith("- [ ]")) { // Чекбокс processedLines.push(indent + "- [ ] " + trimmedLine); } else if (before.startsWith("- ")) { // Маркированный список processedLines.push(indent + "- " + trimmedLine); } else if (before.match(/^\d+\.\s/)) { // Нумерованный список - извлекаем начальный номер const numberMatch = before.match(/^(\d+)\.\s/); if (numberMatch && isFirstNonEmptyLine) { // Для первой непустой строки используем номер из маркера currentNumber = parseInt(numberMatch[1]); isFirstNonEmptyLine = false; } else if (isFirstNonEmptyLine) { // Если маркера нет, начинаем с 1 currentNumber = 1; isFirstNonEmptyLine = false; } processedLines.push(indent + currentNumber + ". " + trimmedLine); currentNumber++; } else if (before.startsWith("> ")) { // Цитата processedLines.push(indent + "> " + trimmedLine); } else { // Для других форматов просто добавляем маркер processedLines.push(indent + before + trimmedLine); } } const processedText = processedLines.join("\n"); newText = editContent.substring(0, start) + processedText + editContent.substring(end); newStart = start + before.length; newEnd = start + processedText.length; } else { // Добавляем теги обычным способом newText = editContent.substring(0, start) + before + selectedText + after + editContent.substring(end); newStart = start + before.length; newEnd = end + before.length; } } setEditContent(newText); // Восстанавливаем фокус и выделение, затем обновляем активные форматы setTimeout(() => { textarea.focus(); textarea.setSelectionRange(newStart, newEnd); // Обновляем активные форматы после применения форматирования const formats = getActiveFormats(); setActiveFormats(formats); }, 0); }, [editContent, 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 = editTextareaRef.current; if (!textarea) return; const start = textarea.selectionStart; const end = textarea.selectionEnd; const selected = editContent.substring(start, end); const before = editContent.substring(0, start); const after = editContent.substring(end); let replacement; if (selected.trim() === "") { replacement = `Текст`; } else { replacement = `${selected}`; } const newText = before + replacement + after; setEditContent(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(); }, [editContent]); // Функция для вычисления позиции курсора в textarea const getCursorPosition = useCallback(() => { const textarea = editTextareaRef.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; // @ts-expect-error - переменная может использоваться в будущем 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 (localPreviewMode || !floatingToolbarEnabled) { setShowFloatingToolbar(false); return; } // Проверяем, есть ли текст в редакторе const hasText = editContent.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 }); } }, [localPreviewMode, editContent, getCursorPosition, getActiveFormats, floatingToolbarEnabled]); 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 + editImages.length > 10) { showNotification("Можно загрузить максимум 10 изображений", "warning"); return; } setEditImages([...editImages, ...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 ); setEditFiles([...editFiles, ...newFiles]); if (fileInputRef.current) { fileInputRef.current.value = ""; } }; // Обработчик клавиш для режима редактирования const handleEditKeyDown = (e: React.KeyboardEvent) => { if ((e.altKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); handleSaveEdit(); } else if (e.key === "Escape") { e.preventDefault(); handleCancelEdit(); } else if (e.key === "Enter") { // Автоматическое продолжение списков const textarea = e.currentTarget; const start = textarea.selectionStart; const text = textarea.value; const lines = text.split("\n"); // Определяем текущую строку // @ts-expect-error - переменная используется для определения текущей строки 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; } // Проверяем, является ли текущая строка списком const listPatterns = [ /^(\s*)- \[ \] /, /^(\s*)- \[x\] /i, /^(\s*)- /, /^(\s*)\* /, /^(\s*)\+ /, /^(\s*)(\d+)\. /, /^(\s*)(\w+)\. /, /^(\s*)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]) { 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; setEditContent(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") { newMarker = indent + "1. "; } const newContent = beforeCursor + "\n" + newMarker + afterCursor; setEditContent(newContent); setTimeout(() => { const newCursorPos = start + 1 + newMarker.length; textarea.setSelectionRange(newCursorPos, newCursorPos); }, 0); } } } }; // Авторасширение textarea в режиме редактирования useEffect(() => { if (!isEditing) return; const textarea = editTextareaRef.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); }; }, [isEditing, editContent]); // Фокусировка на textarea при переходе в режим редактирования useEffect(() => { if (isEditing && editTextareaRef.current && !localPreviewMode) { setTimeout(() => { const textarea = editTextareaRef.current; if (textarea) { textarea.focus(); // Устанавливаем курсор в конец текста только при первом входе в режим редактирования if (shouldSetCursorToEndRef.current) { // Используем актуальное значение из textarea, а не из состояния const contentLength = textarea.value.length; textarea.setSelectionRange(contentLength, contentLength); shouldSetCursorToEndRef.current = false; // Сбрасываем флаг после установки } } }, 100); } }, [isEditing, localPreviewMode]); // Убрали все зависимости от editContent // Отслеживание выделения текста useEffect(() => { if (!isEditing) return; const textarea = editTextareaRef.current; if (!textarea || localPreviewMode) 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); }; }, [isEditing, isPreviewMode, handleSelection]); // Скрываем toolbar при клике вне textarea и toolbar useEffect(() => { if (!isEditing) return; const handleClickOutside = (e: MouseEvent) => { const textarea = editTextareaRef.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); }; }, [isEditing]); // Обновляем позицию toolbar при прокрутке useEffect(() => { if (!isEditing || !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 = editTextareaRef.current; if (textarea) { textarea.addEventListener("scroll", handleScroll); window.addEventListener("scroll", handleScroll, true); } return () => { if (textarea) { textarea.removeEventListener("scroll", handleScroll); } window.removeEventListener("scroll", handleScroll, true); }; }, [isEditing, showFloatingToolbar, getCursorPosition, getActiveFormats]); const handleArchiveConfirm = () => { setShowArchiveModal(false); onArchive(note.id); }; // Форматируем время, убирая лишний ноль в конце (например, "19:420" -> "19:42") const fixTime = (time: string | undefined): string => { if (!time) return time || ""; // Обрезаем все лишние цифры после формата HH:mm (первые 5 символов) // Например, "19:420" -> "19:42", "19:421" -> "19:42" if (time.length > 5 && time.match(/^\d{2}:\d{2}/)) { return time.substring(0, 5); } return time; }; // Форматируем дату const formatDate = () => { if (note.created_at) { const created = parseSQLiteUtc(note.created_at); const createdStr = formatLocalDateTime(created); // Убеждаемся, что строка не содержит лишних символов const cleanCreatedStr = createdStr.replace( /(\d{2}\.\d{2}\.\d{4} \d{2}:\d{2})\d*.*/, "$1" ); if (note.updated_at && note.created_at !== note.updated_at) { const showEditDate = user?.show_edit_date !== undefined ? user.show_edit_date === 1 : true; const updated = parseSQLiteUtc(note.updated_at); const updatedStr = formatLocalDateTime(updated); // Убеждаемся, что строка не содержит лишних символов const cleanUpdatedStr = updatedStr.replace( /(\d{2}\.\d{2}\.\d{4} \d{2}:\d{2})\d*.*/, "$1" ); if (showEditDate) { return ( <> {cleanCreatedStr} | {cleanUpdatedStr} ); } else { return ( <> {cleanCreatedStr} ); } } else { return cleanCreatedStr; } } else { const fixedTime = fixTime(note.time); return `${note.date} ${fixedTime}`; } }; // Форматируем контент const formatContent = () => { let contentToProcess = note.content; if (searchQuery) { contentToProcess = highlightSearchText(contentToProcess, searchQuery); } const contentWithClickableTags = makeTagsClickable(contentToProcess); return parseMarkdown(contentWithClickableTags); }; const getFileIcon = (filename: string): string => { const ext = filename.split(".").pop()?.toLowerCase() || ""; if (ext === "pdf") return "mdi:file-pdf"; if (["doc", "docx"].includes(ext)) return "mdi:file-word"; if (["xls", "xlsx"].includes(ext)) return "mdi:file-excel"; if (ext === "txt") return "mdi:file-document"; if (["zip", "rar", "7z"].includes(ext)) return "mdi:folder-zip"; return "mdi:file"; }; const formatFileSize = (bytes: number): string => { return (bytes / 1024 / 1024).toFixed(2) + " MB"; }; const handleImageClick = (imagePath: string) => { const modal = document.getElementById("imageModal"); const modalImage = document.getElementById("modalImage"); if (modal && modalImage) { modalImage.setAttribute("src", imagePath); modal.style.display = "block"; } }; const handleTagClick = (e: React.MouseEvent, tag: string) => { e.stopPropagation(); // Всегда сохраняем тег в нижнем регистре для единообразия dispatch(setSelectedTag(tag.toLowerCase())); }; const toggleExpand = () => { setIsExpanded(!isExpanded); }; // Проверка, является ли заметка длинной useEffect(() => { if (isEditing) { setIsExpanded(false); setIsLongNote(false); return; } if (!textNoteRef.current) return; const checkNoteLength = () => { const element = textNoteRef.current; if (!element) return; // Временно убираем класс collapsed для точного измерения const wasCollapsed = element.classList.contains("collapsed"); if (wasCollapsed) { element.classList.remove("collapsed"); } // Получаем реальную высоту контента const scrollHeight = element.scrollHeight; // Восстанавливаем класс если он был if (wasCollapsed && !isExpanded) { element.classList.add("collapsed"); } // Считаем заметку длинной, если она больше 300px (максимальная высота свернутой заметки) const isLong = scrollHeight > 300; setIsLongNote(isLong); }; // Небольшая задержка для того, чтобы контент успел отрендериться const timer = setTimeout(checkNoteLength, 100); return () => clearTimeout(timer); }, [note.content, isEditing, isExpanded]); // Сбрасываем состояние раскрытия при изменении заметки useEffect(() => { setIsExpanded(false); }, [note.id]); return ( <>
{formatDate()} {note.is_pinned ? ( Закреплено ) : null} {note.syncStatus === 'pending' && ( )} {note.syncStatus === 'error' && ( )}
onPin(note.id)} title={note.is_pinned ? "Открепить" : "Закрепить"} >
onSelect && onSelect(note.id)} onClick={(e) => e.stopPropagation()} /> {/*
*/}
{isEditing ? (
{!localPreviewMode && ( <>