1433 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { notesApi } from "../../api/notesApi";
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";
interface NoteItemProps {
note: Note;
onDelete: (id: number) => void;
onPin: (id: number) => void;
onArchive: (id: number) => void;
onReload: () => void;
}
export const NoteItem: React.FC<NoteItemProps> = ({
note,
onDelete,
onPin,
onArchive,
onReload,
}) => {
const [isEditing, setIsEditing] = useState(false);
const [editContent, setEditContent] = useState(note.content);
const [showArchiveModal, setShowArchiveModal] = useState(false);
const [editImages, setEditImages] = useState<File[]>([]);
const [editFiles, setEditFiles] = useState<File[]>([]);
const [deletedImageIds, setDeletedImageIds] = useState<number[]>([]);
const [deletedFileIds, setDeletedFileIds] = useState<number[]>([]);
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 editTextareaRef = useRef<HTMLTextAreaElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const textNoteRef = useRef<HTMLDivElement>(null);
const searchQuery = useAppSelector((state) => state.notes.searchQuery);
const isPreviewMode = useAppSelector((state) => state.ui.isPreviewMode);
const aiEnabled = useAppSelector((state) => state.profile.aiEnabled);
const { showNotification } = useNotification();
const dispatch = useAppDispatch();
useMarkdown({ onNoteUpdate: onReload }); // Инициализируем обработчики спойлеров, внешних ссылок и чекбоксов
const handleEdit = () => {
setIsEditing(true);
setEditContent(note.content);
setEditImages([]);
setEditFiles([]);
setDeletedImageIds([]);
setDeletedFileIds([]);
setShowFloatingToolbar(false);
setActiveFormats({ bold: false, italic: false, strikethrough: false });
setLocalPreviewMode(false);
};
const toggleLocalPreviewMode = () => {
setLocalPreviewMode(!localPreviewMode);
setShowFloatingToolbar(false);
};
const handleSaveEdit = async () => {
if (!editContent.trim()) {
showNotification("Введите текст заметки", "warning");
return;
}
try {
await notesApi.update(note.id, editContent);
// Удаляем выбранные изображения
for (const imageId of deletedImageIds) {
await notesApi.deleteImage(note.id, imageId);
}
// Удаляем выбранные файлы
for (const fileId of deletedFileIds) {
await notesApi.deleteFile(note.id, fileId);
}
// Загружаем новые изображения
if (editImages.length > 0) {
await notesApi.uploadImages(note.id, editImages);
}
// Загружаем новые файлы
if (editFiles.length > 0) {
await notesApi.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) => {
setDeletedImageIds([...deletedImageIds, imageId]);
};
const handleDeleteExistingFile = (fileId: number) => {
setDeletedFileIds([...deletedFileIds, fileId]);
};
const handleRestoreImage = (imageId: number) => {
setDeletedImageIds(deletedImageIds.filter((id) => id !== imageId));
};
const handleRestoreFile = (fileId: number) => {
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 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 {
// Добавляем теги
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 = `<span style="color: ${selectedColor}">Текст</span>`;
} else {
replacement = `<span style="color: ${selectedColor}">${selected}</span>`;
}
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;
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) {
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]);
const handleImageButtonClick = () => {
imageInputRef.current?.click();
};
const handleFileButtonClick = () => {
fileInputRef.current?.click();
};
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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<HTMLTextAreaElement>) => {
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");
// Определяем текущую строку
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);
}
}
}
};
const handleArchiveClick = () => {
setShowArchiveModal(true);
};
// Авторасширение 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(() => {
editTextareaRef.current?.focus();
// Устанавливаем курсор в конец текста
const textarea = editTextareaRef.current;
if (textarea) {
textarea.setSelectionRange(editContent.length, editContent.length);
}
}, 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 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"
);
return (
<>
{cleanCreatedStr}
<span className="date-separator"> | </span>
<Icon
icon="mdi:pencil"
style={{ fontSize: "12px", margin: "0 2px" }}
/>
{cleanUpdatedStr}
</>
);
} 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));
};
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 (
<>
<div
className={`container ${note.is_pinned ? "note-pinned" : ""}`}
data-note-id={note.id}
>
<div className="date">
<span className="date-text">
{formatDate()}
{note.is_pinned ? (
<span className="pin-indicator">
<Icon icon="mdi:pin" />
Закреплено
</span>
) : null}
</span>
<div className="note-actions">
<div
className="notesHeaderBtn"
onClick={() => onPin(note.id)}
title={note.is_pinned ? "Открепить" : "Закрепить"}
>
<Icon icon={note.is_pinned ? "mdi:pin-off" : "mdi:pin"} />
</div>
<div
className="notesHeaderBtn"
onClick={handleEdit}
title="Редактировать"
>
<Icon icon="mdi:pencil" />
</div>
<div
className="notesHeaderBtn"
onClick={handleArchiveClick}
title="В архив"
>
<Icon icon="mdi:delete" />
</div>
</div>
</div>
{isEditing ? (
<div className="note-edit-mode">
<MarkdownToolbar
onInsert={insertMarkdown}
onImageClick={handleImageButtonClick}
onFileClick={handleFileButtonClick}
onPreviewToggle={toggleLocalPreviewMode}
isPreviewMode={localPreviewMode}
/>
<input
ref={imageInputRef}
type="file"
id="imageInput"
accept="image/*"
multiple
style={{ display: "none" }}
onChange={handleImageSelect}
/>
<input
ref={fileInputRef}
type="file"
id="fileInput"
accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,.zip,.rar,.7z"
multiple
style={{ display: "none" }}
onChange={handleFileSelect}
/>
{!localPreviewMode && (
<>
<textarea
ref={editTextareaRef}
className="textInput"
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
onKeyDown={handleEditKeyDown}
style={{ minHeight: "100px" }}
onContextMenu={(e) => {
// Предотвращаем появление контекстного меню браузера при выделении текста
// Но разрешаем его, если редактор пустой (чтобы можно было вставить текст)
const textarea = editTextareaRef.current;
if (textarea) {
const hasText = textarea.value.trim().length > 0;
const hasSelection = textarea.selectionStart !== textarea.selectionEnd;
// Блокируем браузерное меню только если есть текст И есть выделение
if (hasText && hasSelection) {
e.preventDefault();
}
}
}}
/>
<FloatingToolbar
textareaRef={editTextareaRef}
onFormat={insertMarkdown}
visible={showFloatingToolbar}
position={toolbarPosition}
onHide={() => setShowFloatingToolbar(false)}
onInsertColor={insertColorMarkdown}
activeFormats={activeFormats}
hasSelection={hasSelection}
/>
</>
)}
{localPreviewMode && <NotePreview content={editContent} />}
{/* Существующие изображения */}
{note.images && note.images.length > 0 && (
<div
className="image-preview-container"
style={{ display: "block" }}
>
<div className="image-preview-header">
<span>Прикрепленные изображения:</span>
</div>
<div className="image-preview-list">
{note.images
.filter((image) => !deletedImageIds.includes(image.id))
.map((image) => {
const imageUrl = getImageUrl(
image.file_path,
note.id,
image.id
);
return (
<div key={image.id} className="image-preview-item">
<img
src={imageUrl}
alt={image.original_name}
className="image-preview-thumbnail"
/>
<button
className="image-preview-remove"
onClick={() => handleDeleteExistingImage(image.id)}
title="Удалить"
>
<Icon icon="mdi:close" />
</button>
</div>
);
})}
</div>
</div>
)}
{/* Удаленные изображения (для возможности восстановления) */}
{deletedImageIds.length > 0 && (
<div
className="image-preview-container"
style={{ display: "block", opacity: 0.5 }}
>
<div className="image-preview-header">
<span>Изображения для удаления:</span>
</div>
<div className="image-preview-list">
{note.images
.filter((image) => deletedImageIds.includes(image.id))
.map((image) => {
const imageUrl = getImageUrl(
image.file_path,
note.id,
image.id
);
return (
<div key={image.id} className="image-preview-item">
<img
src={imageUrl}
alt={image.original_name}
className="image-preview-thumbnail"
style={{ opacity: 0.5 }}
/>
<button
className="image-preview-remove restore-btn"
onClick={() => handleRestoreImage(image.id)}
title="Восстановить"
>
<Icon icon="mdi:restore" />
</button>
</div>
);
})}
</div>
</div>
)}
{/* Существующие файлы */}
{note.files && note.files.length > 0 && (
<div
className="file-preview-container"
style={{ display: "block" }}
>
<div className="file-preview-header">
<span>Прикрепленные файлы:</span>
</div>
<div className="file-preview-list">
{note.files
.filter((file) => !deletedFileIds.includes(file.id))
.map((file) => {
const fileUrl = getFileUrl(
file.file_path,
note.id,
file.id
);
return (
<div key={file.id} className="file-preview-item">
<Icon
icon={getFileIcon(file.original_name)}
className="file-icon"
/>
<div className="file-info">
<div className="file-name">
{file.original_name}
</div>
<div className="file-size">
{formatFileSize(file.file_size)}
</div>
</div>
<button
className="file-preview-remove"
onClick={() => handleDeleteExistingFile(file.id)}
title="Удалить"
>
<Icon icon="mdi:close" />
</button>
</div>
);
})}
</div>
</div>
)}
{/* Удаленные файлы (для возможности восстановления) */}
{deletedFileIds.length > 0 && (
<div
className="file-preview-container"
style={{ display: "block", opacity: 0.5 }}
>
<div className="file-preview-header">
<span>Файлы для удаления:</span>
</div>
<div className="file-preview-list">
{note.files
.filter((file) => deletedFileIds.includes(file.id))
.map((file) => {
return (
<div key={file.id} className="file-preview-item">
<Icon
icon={getFileIcon(file.original_name)}
className="file-icon"
style={{ opacity: 0.5 }}
/>
<div className="file-info">
<div className="file-name" style={{ opacity: 0.5 }}>
{file.original_name}
</div>
<div className="file-size" style={{ opacity: 0.5 }}>
{formatFileSize(file.file_size)}
</div>
</div>
<button
className="file-preview-remove restore-btn"
onClick={() => handleRestoreFile(file.id)}
title="Восстановить"
>
<Icon icon="mdi:restore" />
</button>
</div>
);
})}
</div>
</div>
)}
<ImageUpload images={editImages} onChange={setEditImages} />
<FileUpload files={editFiles} onChange={setEditFiles} />
<div className="save-button-container">
<div className="action-buttons">
{aiEnabled && (
<button
className="btnSave btnAI"
onClick={handleAiImprove}
disabled={isAiLoading}
title="Улучшить или создать текст через ИИ"
>
<Icon icon="mdi:robot" />
{isAiLoading ? "Обработка..." : "Помощь ИИ"}
</button>
)}
<button className="btnSave" onClick={handleSaveEdit}>
Сохранить
</button>
<button className="btn-secondary" onClick={handleCancelEdit}>
Отмена
</button>
</div>
<span className="save-hint">
Alt + Enter для сохранения, Esc для отмены
</span>
</div>
</div>
) : (
<>
<div
ref={textNoteRef}
className={`textNote ${isLongNote && !isExpanded ? "collapsed" : ""}`}
data-original-content={note.content}
dangerouslySetInnerHTML={{ __html: formatContent() }}
onClick={(e) => {
const target = e.target as HTMLElement;
if (target.classList.contains("tag-in-note")) {
const tag = target.getAttribute("data-tag");
if (tag) {
handleTagClick(e, tag);
}
}
}}
/>
{isLongNote && (
<button
className="show-more-btn"
onClick={toggleExpand}
type="button"
>
<Icon icon={isExpanded ? "mdi:chevron-up" : "mdi:chevron-down"} />
<span>{isExpanded ? "Скрыть" : "Раскрыть"}</span>
</button>
)}
{note.images && note.images.length > 0 && (
<div className="note-images-container">
{note.images.map((image) => {
const imageUrl = getImageUrl(
image.file_path,
note.id,
image.id
);
return (
<div key={image.id} className="note-image-item">
<img
src={imageUrl}
alt={image.original_name}
className="note-image lazy"
data-src={imageUrl}
data-image-id={image.id}
loading="lazy"
onClick={() => handleImageClick(imageUrl)}
/>
</div>
);
})}
</div>
)}
{note.files && note.files.length > 0 && (
<div className="note-files-container">
{note.files.map((file) => {
const fileUrl = getFileUrl(file.file_path, note.id, file.id);
return (
<div key={file.id} className="note-file-item">
<a
href={fileUrl}
download={file.original_name}
className="note-file-link"
data-file-id={file.id}
>
<Icon
icon={getFileIcon(file.original_name)}
className="file-icon"
/>
<div className="file-info">
<div className="file-name">{file.original_name}</div>
<div className="file-size">
{formatFileSize(file.file_size)}
</div>
</div>
</a>
</div>
);
})}
</div>
)}
</>
)}
</div>
<Modal
isOpen={showArchiveModal}
onClose={() => setShowArchiveModal(false)}
onConfirm={handleArchiveConfirm}
title="Подтверждение архивирования"
message="Архивировать эту заметку? Её можно будет восстановить из настроек."
confirmText="Архивировать"
cancelText="Отмена"
/>
</>
);
};