1633 lines
62 KiB
TypeScript
1633 lines
62 KiB
TypeScript
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<NoteItemProps> = ({
|
||
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<File[]>([]);
|
||
const [editFiles, setEditFiles] = useState<File[]>([]);
|
||
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<string[]>([]);
|
||
const [isGeneratingTags, setIsGeneratingTags] = useState(false);
|
||
const [tagsGenerationError, setTagsGenerationError] = useState(false);
|
||
const editTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const textNoteRef = useRef<HTMLDivElement>(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 = `<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;
|
||
// @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<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");
|
||
|
||
// Определяем текущую строку
|
||
// @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}
|
||
<span className="date-separator"> | </span>
|
||
<Icon
|
||
icon="mdi:pencil"
|
||
style={{ fontSize: "12px", margin: "0 2px" }}
|
||
/>
|
||
{cleanUpdatedStr}
|
||
</>
|
||
);
|
||
} else {
|
||
return (
|
||
<>
|
||
{cleanCreatedStr}
|
||
<Icon
|
||
icon="mdi:pencil"
|
||
style={{ fontSize: "12px", margin: "0 2px" }}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
} 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 (
|
||
<>
|
||
<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}
|
||
{note.syncStatus === 'pending' && (
|
||
<span className="sync-indicator" title="Ожидает синхронизации">
|
||
<Icon icon="mdi:cloud-upload" />
|
||
</span>
|
||
)}
|
||
{note.syncStatus === 'error' && (
|
||
<span className="sync-error-indicator" title="Ошибка синхронизации">
|
||
<Icon icon="mdi:cloud-alert" />
|
||
</span>
|
||
)}
|
||
</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>
|
||
<input
|
||
type="checkbox"
|
||
checked={isSelected}
|
||
onChange={() => onSelect && onSelect(note.id)}
|
||
onClick={(e) => e.stopPropagation()}
|
||
/>
|
||
{/* <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();
|
||
}
|
||
}
|
||
}}
|
||
/>
|
||
{floatingToolbarEnabled && (
|
||
<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,
|
||
Number(note.id),
|
||
Number(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,
|
||
Number(note.id),
|
||
Number(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) => {
|
||
// fileUrl не используется в этом контексте
|
||
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" />
|
||
<span className="btnAI-text">
|
||
{isAiLoading ? "Обработка..." : "Помощь ИИ"}
|
||
</span>
|
||
</button>
|
||
<button
|
||
className="btnSave btnAI"
|
||
onClick={handleGenerateTags}
|
||
disabled={isGeneratingTags || isAiLoading}
|
||
title="Сгенерировать теги через ИИ"
|
||
>
|
||
<Icon icon="mdi:tag-multiple" />
|
||
<span className="btnAI-text">
|
||
{isGeneratingTags ? "Генерация..." : "Теги ИИ"}
|
||
</span>
|
||
</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,
|
||
Number(note.id),
|
||
Number(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, Number(note.id), Number(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="Отмена"
|
||
/>
|
||
|
||
<GenerateTagsModal
|
||
isOpen={showTagsModal}
|
||
onClose={() => {
|
||
setShowTagsModal(false);
|
||
setSuggestedTags([]);
|
||
setTagsGenerationError(false);
|
||
}}
|
||
onSelectTags={handleSelectTags}
|
||
suggestedTags={suggestedTags}
|
||
existingTags={extractTags(editContent)}
|
||
isLoading={isGeneratingTags}
|
||
hasError={tagsGenerationError}
|
||
/>
|
||
</>
|
||
);
|
||
};
|