noteJS-react/src/components/notes/NoteEditor.tsx

1014 lines
38 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, useCallback, useEffect } from "react";
import { MarkdownToolbar } from "./MarkdownToolbar";
import { FloatingToolbar } from "./FloatingToolbar";
import { NotePreview } from "./NotePreview";
import { ImageUpload } from "./ImageUpload";
import { FileUpload } from "./FileUpload";
import { useAppSelector } from "../../store/hooks";
import { useNotification } from "../../hooks/useNotification";
import { offlineNotesApi } from "../../api/offlineNotesApi";
import { aiApi } from "../../api/aiApi";
import { Icon } from "@iconify/react";
interface NoteEditorProps {
onSave: () => void;
}
export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
const [content, setContent] = useState("");
const [images, setImages] = useState<File[]>([]);
const [files, setFiles] = useState<File[]>([]);
const [isAiLoading, setIsAiLoading] = useState(false);
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: 0 });
const [hasSelection, setHasSelection] = useState(false);
const [activeFormats, setActiveFormats] = useState({
bold: false,
italic: false,
strikethrough: false,
});
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isPreviewMode = useAppSelector((state) => state.ui.isPreviewMode);
const { showNotification } = useNotification();
const aiEnabled = useAppSelector((state) => state.profile.aiEnabled);
const user = useAppSelector((state) => state.profile.user);
// Проверяем, включена ли плавающая панель
const floatingToolbarEnabled =
user?.floating_toolbar_enabled !== undefined
? user.floating_toolbar_enabled === 1
: true;
const handleSave = async () => {
if (!content.trim()) {
showNotification("Введите текст заметки", "warning");
return;
}
try {
const now = new Date();
const date = now.toLocaleDateString("ru-RU");
const time = now.toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
});
const note = await offlineNotesApi.create({ content, date, time });
// Загружаем изображения
if (images.length > 0) {
await offlineNotesApi.uploadImages(note.id, images);
}
// Загружаем файлы
if (files.length > 0) {
await offlineNotesApi.uploadFiles(note.id, files);
}
showNotification("Заметка сохранена!", "success");
setContent("");
setImages([]);
setFiles([]);
onSave();
} catch (error) {
console.error("Ошибка сохранения заметки:", error);
showNotification("Ошибка сохранения заметки", "error");
}
};
const handleAiImprove = async () => {
if (!content.trim()) {
showNotification("Введите текст для улучшения", "warning");
return;
}
setIsAiLoading(true);
try {
const improvedText = await aiApi.improveText(content);
setContent(improvedText);
showNotification("Текст улучшен!", "success");
} catch (error) {
console.error("Ошибка улучшения текста:", error);
showNotification("Ошибка улучшения текста", "error");
} finally {
setIsAiLoading(false);
}
};
// Функция для определения активных форматов в выделенном тексте
const getActiveFormats = useCallback(() => {
const textarea = textareaRef.current;
if (!textarea) {
return { bold: false, italic: false, strikethrough: false };
}
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
// Если нет выделения, возвращаем все false
if (start === end) {
return { bold: false, italic: false, strikethrough: false };
}
const selectedText = content.substring(start, end);
// Инициализируем форматы
const formats = {
bold: false,
italic: false,
strikethrough: false,
};
// Расширяем область проверки для захвата всех возможных тегов
const checkRange = 10; // Проверяем до 10 символов перед и после
const checkStart = Math.max(0, start - checkRange);
const checkEnd = Math.min(content.length, end + checkRange);
const contextText = content.substring(checkStart, checkEnd);
const selectionInContext = start - checkStart;
// Текст вокруг выделения
const beforeContext = contextText.substring(0, selectionInContext);
const afterContext = contextText.substring(
selectionInContext + selectedText.length
);
// Функция для подсчета символов в конце строки
const countTrailingChars = (text: string, char: string) => {
let count = 0;
for (let i = text.length - 1; i >= 0; i--) {
if (text[i] === char) count++;
else break;
}
return count;
};
// Функция для подсчета символов в начале строки
const countLeadingChars = (text: string, char: string) => {
let count = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] === char) count++;
else break;
}
return count;
};
// Проверяем зачеркнутый (~~) - проверяем первым, так как он не пересекается с другими
const strikethroughBefore = beforeContext.slice(-2);
const strikethroughAfter = afterContext.slice(0, 2);
const hasStrikethroughAround =
strikethroughBefore === "~~" && strikethroughAfter === "~~";
const hasStrikethroughInside =
selectedText.startsWith("~~") &&
selectedText.endsWith("~~") &&
selectedText.length >= 4;
if (hasStrikethroughAround || hasStrikethroughInside) {
formats.strikethrough = true;
}
// Проверяем звездочки для жирного и курсива
// Подсчитываем количество звездочек в конце контекста перед выделением
const trailingStars = countTrailingChars(beforeContext, "*");
const leadingStars = countLeadingChars(afterContext, "*");
const leadingStarsInSelection = countLeadingChars(selectedText, "*");
const trailingStarsInSelection = countTrailingChars(selectedText, "*");
// Проверяем жирный (**)
// Жирный требует минимум 2 звездочки с каждой стороны
const hasBoldStarsBefore = trailingStars >= 2;
const hasBoldStarsAfter = leadingStars >= 2;
const hasBoldStarsInSelection =
leadingStarsInSelection >= 2 && trailingStarsInSelection >= 2;
// Проверяем, что это действительно жирный
if (hasBoldStarsBefore && hasBoldStarsAfter) {
formats.bold = true;
} else if (hasBoldStarsInSelection && selectedText.length >= 4) {
formats.bold = true;
}
// Проверяем курсив (*)
// Если есть 3+ звездочки с каждой стороны, это комбинация жирного и курсива
// Если есть 1 звездочка с каждой стороны (и не 2), это курсив
// Если есть 3+ звездочки, это жирный + курсив
const exactItalicBefore =
trailingStars === 1 || (trailingStars >= 3 && trailingStars % 2 === 1);
const exactItalicAfter =
leadingStars === 1 || (leadingStars >= 3 && leadingStars % 2 === 1);
const exactItalicInSelection =
(leadingStarsInSelection === 1 && trailingStarsInSelection === 1) ||
(leadingStarsInSelection >= 3 &&
trailingStarsInSelection >= 3 &&
leadingStarsInSelection % 2 === 1 &&
trailingStarsInSelection % 2 === 1);
// Проверяем также внутри выделения
if (exactItalicBefore && exactItalicAfter && !formats.bold) {
// Если перед и после по 1 звездочке, это курсив
formats.italic = true;
} else if (trailingStars >= 3 && leadingStars >= 3) {
// Если по 3+ звездочки, это комбинация жирного и курсива
formats.italic = true;
formats.bold = true;
} else if (exactItalicInSelection && selectedText.length >= 2) {
formats.italic = true;
} else if (
leadingStarsInSelection === 1 &&
trailingStarsInSelection === 1 &&
selectedText.length >= 2 &&
!selectedText.startsWith("**") &&
!selectedText.endsWith("**")
) {
formats.italic = true;
}
// Если определен жирный, но не определен курсив, и есть 3+ звездочки, значит курсив тоже есть
if (
formats.bold &&
(trailingStars >= 3 ||
leadingStars >= 3 ||
leadingStarsInSelection >= 3 ||
trailingStarsInSelection >= 3)
) {
formats.italic = true;
}
return formats;
}, [content]);
const insertMarkdown = useCallback(
(before: string, after: string = "") => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = content.substring(start, end);
const tagLength = before.length;
// Проверяем область вокруг выделения (расширяем для проверки тегов)
const checkStart = Math.max(0, start - tagLength);
const checkEnd = Math.min(content.length, end + tagLength);
const contextText = content.substring(checkStart, checkEnd);
const selectionInContext = start - checkStart;
// Текст перед и после выделения в контексте
const beforeContext = contextText.substring(0, selectionInContext);
const afterContext = contextText.substring(
selectionInContext + selectedText.length
);
// Проверяем точное совпадение тегов
const hasTagsBefore = beforeContext.endsWith(before);
const hasTagsAfter = afterContext.startsWith(after);
// Проверяем, есть ли теги внутри выделенного текста
const startsWithTag = selectedText.startsWith(before);
const endsWithTag = selectedText.endsWith(after);
// Определяем, нужно ли удалять теги
let shouldRemoveTags = false;
if (before === "*" && after === "*") {
// Для курсива нужно убедиться, что это не часть ** (жирного)
const beforeBeforeChar = start > 1 ? content[start - 2] : "";
const afterAfterChar = end + 1 < content.length ? content[end + 1] : "";
// Проверяем, что перед * не стоит еще один *, и после * не стоит еще один *
const isItalicBefore = hasTagsBefore && beforeBeforeChar !== "*";
const isItalicAfter = hasTagsAfter && afterAfterChar !== "*";
// Проверяем внутри выделения
const hasItalicInside =
startsWithTag &&
endsWithTag &&
selectedText.length >= 2 &&
!selectedText.startsWith("**") &&
!selectedText.endsWith("**");
shouldRemoveTags = (isItalicBefore && isItalicAfter) || hasItalicInside;
} else if (before === "**" && after === "**") {
// Для жирного проверяем точное совпадение **
shouldRemoveTags =
(hasTagsBefore && hasTagsAfter) ||
(startsWithTag && endsWithTag && selectedText.length >= 4);
} else if (before === "~~" && after === "~~") {
// Для зачеркнутого проверяем точное совпадение ~~
shouldRemoveTags =
(hasTagsBefore && hasTagsAfter) ||
(startsWithTag && endsWithTag && selectedText.length >= 4);
} else {
// Для других тегов (например, ||)
shouldRemoveTags =
(hasTagsBefore && hasTagsAfter) ||
(startsWithTag &&
endsWithTag &&
selectedText.length >= tagLength * 2);
}
let newText: string;
let newStart: number;
let newEnd: number;
if (shouldRemoveTags) {
// Удаляем существующие теги
if (hasTagsBefore && hasTagsAfter) {
// Теги находятся вокруг выделения (в контексте)
newText =
content.substring(0, start - tagLength) +
selectedText +
content.substring(end + tagLength);
newStart = start - tagLength;
newEnd = end - tagLength;
} else {
// Теги находятся внутри выделенного текста
const innerText = selectedText.substring(
tagLength,
selectedText.length - tagLength
);
newText =
content.substring(0, start) + innerText + content.substring(end);
newStart = start;
newEnd = start + innerText.length;
}
} else {
// Проверяем, является ли это форматированием списка или цитаты
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 =
content.substring(0, start) +
processedText +
content.substring(end);
newStart = start + before.length;
newEnd = start + processedText.length;
} else {
// Добавляем теги обычным способом
newText =
content.substring(0, start) +
before +
selectedText +
after +
content.substring(end);
newStart = start + before.length;
newEnd = end + before.length;
}
}
setContent(newText);
// Восстанавливаем фокус и выделение, затем обновляем активные форматы
setTimeout(() => {
textarea.focus();
textarea.setSelectionRange(newStart, newEnd);
// Обновляем активные форматы после применения форматирования
const formats = getActiveFormats();
setActiveFormats(formats);
}, 0);
},
[content, getActiveFormats]
);
const insertColorMarkdown = useCallback(() => {
const colorDialog = document.createElement("input");
colorDialog.type = "color";
colorDialog.style.display = "none";
document.body.appendChild(colorDialog);
colorDialog.addEventListener("change", function () {
const selectedColor = this.value;
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selected = content.substring(start, end);
const before = content.substring(0, start);
const after = content.substring(end);
let replacement;
if (selected.trim() === "") {
replacement = `<span style="color: ${selectedColor}">Текст</span>`;
} else {
replacement = `<span style="color: ${selectedColor}">${selected}</span>`;
}
const newText = before + replacement + after;
setContent(newText);
setTimeout(() => {
textarea.focus();
const cursorPosition = start + replacement.length;
textarea.setSelectionRange(cursorPosition, cursorPosition);
}, 0);
document.body.removeChild(this);
});
colorDialog.addEventListener("cancel", function () {
document.body.removeChild(this);
});
colorDialog.click();
}, [content]);
// Ctrl/Alt + Enter для сохранения и автопродолжение списков
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if ((e.altKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
handleSave();
} else if (e.key === "Enter") {
// Автоматическое продолжение списков
const textarea = e.currentTarget;
const start = textarea.selectionStart;
const text = textarea.value;
const lines = text.split("\n");
// Определяем текущую строку
// @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; // +1 для символа новой строки
}
// Проверяем, является ли текущая строка списком
const listPatterns = [
/^(\s*)- \[ \] /, // Чекбокс (не отмечен): - [ ]
/^(\s*)- \[x\] /i, // Чекбокс (отмечен): - [x]
/^(\s*)- /, // Неупорядоченный список: -
/^(\s*)\* /, // Неупорядоченный список: *
/^(\s*)\+ /, // Неупорядоченный список: +
/^(\s*)(\d+)\. /, // Упорядоченный список: 1. 2. 3.
/^(\s*)(\w+)\. /, // Буквенный список: a. b. c.
/^(\s*)1\. /, // Нумерованный список (начинается с 1.)
];
let listMatch = null;
let listType = null;
for (const pattern of listPatterns) {
const match = currentLine.match(pattern);
if (match) {
listMatch = match;
if (pattern === listPatterns[0] || pattern === listPatterns[1]) {
listType = "checkbox";
} else if (
pattern === listPatterns[2] ||
pattern === listPatterns[3] ||
pattern === listPatterns[4]
) {
listType = "unordered";
} else if (pattern === listPatterns[7]) {
// Нумерованный список всегда начинается с 1.
listType = "numbered";
} else {
listType = "ordered";
}
break;
}
}
if (listMatch) {
e.preventDefault();
const indent = listMatch[1] || ""; // Отступы перед маркером
const marker = listMatch[0].slice(indent.length); // Маркер списка без отступов
// Получаем текст после маркера
const afterMarker = currentLine.slice(listMatch[0].length);
if (afterMarker.trim() === "") {
// Если строка пустая после маркера, выходим из списка
const beforeCursor = text.substring(0, start);
const afterCursor = text.substring(start);
// Удаляем маркер и отступы текущей строки
const newBefore = beforeCursor.replace(
/\n\s*- \[ \] \s*$|\n\s*- \[x\] \s*$|\n\s*[-*+]\s*$|\n\s*\d+\.\s*$|\n\s*\w+\.\s*$/i,
"\n"
);
const newContent = newBefore + afterCursor;
setContent(newContent);
// Устанавливаем курсор после удаленного маркера
setTimeout(() => {
const newCursorPos = newBefore.length;
textarea.setSelectionRange(newCursorPos, newCursorPos);
}, 0);
} else {
// Продолжаем список
const beforeCursor = text.substring(0, start);
const afterCursor = text.substring(start);
let newMarker = "";
if (listType === "checkbox") {
// Для чекбоксов всегда создаем новый пустой чекбокс
newMarker = indent + "- [ ] ";
} else if (listType === "unordered") {
newMarker = indent + marker;
} else if (listType === "ordered") {
// Для упорядоченных списков увеличиваем номер
const number = parseInt(listMatch[2]);
const nextNumber = number + 1;
const numberStr = listMatch[2].replace(
/\d+/,
nextNumber.toString()
);
newMarker = indent + numberStr + ". ";
} else if (listType === "numbered") {
// Для нумерованного списка всегда начинаем с 1.
newMarker = indent + "1. ";
}
const newContent = beforeCursor + "\n" + newMarker + afterCursor;
setContent(newContent);
// Устанавливаем курсор после нового маркера
setTimeout(() => {
const newCursorPos = start + 1 + newMarker.length;
textarea.setSelectionRange(newCursorPos, newCursorPos);
}, 0);
}
}
}
};
// Функция для вычисления позиции курсора в textarea
const getCursorPosition = useCallback(() => {
const textarea = textareaRef.current;
if (!textarea) return null;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const hasSelection = start !== end;
// Получаем размеры 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 scrollTop = textarea.scrollTop;
// Вычисляем координаты начала выделения
const textBeforeStart = textarea.value.substring(0, start);
const linesBeforeStart = textBeforeStart.split("\n");
const startLineNumber = linesBeforeStart.length - 1;
const startLineText = linesBeforeStart[startLineNumber];
// Создаем временный элемент для измерения ширины текста
const measureEl = document.createElement("span");
measureEl.style.position = "absolute";
measureEl.style.visibility = "hidden";
measureEl.style.whiteSpace = "pre";
measureEl.style.font = styles.font;
document.body.appendChild(measureEl);
measureEl.textContent = startLineText;
const startTextWidth = measureEl.offsetWidth;
// Вычисляем координаты конца выделения
const textBeforeEnd = textarea.value.substring(0, end);
const linesBeforeEnd = textBeforeEnd.split("\n");
const endLineNumber = linesBeforeEnd.length - 1;
const endLineText = linesBeforeEnd[endLineNumber];
measureEl.textContent = endLineText;
const endTextWidth = measureEl.offsetWidth;
document.body.removeChild(measureEl);
// Вычисляем вертикальные координаты
const startTop =
rect.top + paddingTop + startLineNumber * lineHeight - scrollTop;
const endTop =
rect.top + paddingTop + endLineNumber * lineHeight - scrollTop;
// Горизонтальные координаты
const startLeft = rect.left + paddingLeft + startTextWidth;
const endLeft = rect.left + paddingLeft + endTextWidth;
// Если есть выделение, используем его границы
if (hasSelection) {
// Вычисляем середину выделения по горизонтали
const left = Math.min(startLeft, endLeft);
const right = Math.max(startLeft, endLeft);
const centerLeft = (left + right) / 2;
// Вертикальная позиция - середина выделения
const top = (startTop + endTop) / 2;
return {
top,
left: centerLeft,
hasSelection,
selectionTop: Math.min(startTop, endTop),
selectionBottom: Math.max(startTop, endTop) + lineHeight,
selectionLeft: left,
selectionRight: right,
};
} else {
// Для курсора без выделения
const top = startTop;
const left = startLeft;
return {
top,
left,
hasSelection,
selectionTop: top,
selectionBottom: top + lineHeight,
selectionLeft: left,
selectionRight: left,
};
}
}, []);
// Обработчик выделения текста
const handleSelection = useCallback(() => {
if (isPreviewMode || !floatingToolbarEnabled) {
setShowFloatingToolbar(false);
return;
}
// Проверяем, есть ли текст в редакторе
const hasText = content.trim().length > 0;
const position = getCursorPosition();
if (position && hasText) {
setToolbarPosition({ top: position.top, left: position.left });
setHasSelection(position.hasSelection);
setShowFloatingToolbar(true);
// Определяем активные форматы только если есть выделение
if (position.hasSelection) {
const formats = getActiveFormats();
setActiveFormats(formats);
} else {
setActiveFormats({ bold: false, italic: false, strikethrough: false });
}
} else {
setShowFloatingToolbar(false);
setHasSelection(false);
setActiveFormats({ bold: false, italic: false, strikethrough: false });
}
}, [
isPreviewMode,
content,
getCursorPosition,
getActiveFormats,
floatingToolbarEnabled,
]);
// Отслеживание выделения текста
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea || isPreviewMode) return;
const handleMouseUp = () => {
setTimeout(handleSelection, 0);
};
const handleMouseMove = (e: MouseEvent) => {
if (e.buttons === 1) {
// Если зажата левая кнопка мыши (выделение)
setTimeout(handleSelection, 0);
}
};
const handleKeyUp = () => {
setTimeout(handleSelection, 0);
};
// Предотвращаем появление контекстного меню браузера при выделении текста
// Но разрешаем его, если редактор пустой (чтобы можно было вставить текст)
const handleContextMenu = (e: MouseEvent) => {
const target = e.target as HTMLElement;
// Проверяем, что событие происходит в textarea
if (target === textarea || textarea.contains(target)) {
const hasText = textarea.value.trim().length > 0;
const hasSelection = textarea.selectionStart !== textarea.selectionEnd;
// Блокируем браузерное меню только если есть текст И есть выделение
if (hasText && hasSelection) {
e.preventDefault();
e.stopPropagation();
}
}
};
textarea.addEventListener("mouseup", handleMouseUp);
textarea.addEventListener("mousemove", handleMouseMove);
textarea.addEventListener("keyup", handleKeyUp);
textarea.addEventListener("contextmenu", handleContextMenu);
document.addEventListener("selectionchange", handleSelection);
document.addEventListener("contextmenu", handleContextMenu, true);
return () => {
textarea.removeEventListener("mouseup", handleMouseUp);
textarea.removeEventListener("mousemove", handleMouseMove);
textarea.removeEventListener("keyup", handleKeyUp);
textarea.removeEventListener("contextmenu", handleContextMenu);
document.removeEventListener("selectionchange", handleSelection);
document.removeEventListener("contextmenu", handleContextMenu, true);
};
}, [isPreviewMode, handleSelection]);
// Скрываем toolbar при клике вне textarea и toolbar
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const textarea = textareaRef.current;
const target = e.target as Node;
// Проверяем, не кликнули ли на toolbar или его кнопки
const floatingToolbar = document.querySelector(".floating-toolbar");
if (floatingToolbar && floatingToolbar.contains(target)) {
// Кликнули на toolbar - не скрываем его
return;
}
if (textarea && !textarea.contains(target)) {
// Кликнули вне textarea и toolbar - скрываем только если нет выделения
setTimeout(() => {
if (textarea.selectionStart === textarea.selectionEnd) {
setShowFloatingToolbar(false);
}
}, 0);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
// Обновляем позицию toolbar при прокрутке
useEffect(() => {
if (!showFloatingToolbar) return;
const handleScroll = () => {
const position = getCursorPosition();
if (position) {
setToolbarPosition({ top: position.top, left: position.left });
setHasSelection(position.hasSelection);
// Обновляем активные форматы при прокрутке только если есть выделение
if (position.hasSelection) {
const formats = getActiveFormats();
setActiveFormats(formats);
}
}
};
const textarea = textareaRef.current;
if (textarea) {
textarea.addEventListener("scroll", handleScroll);
window.addEventListener("scroll", handleScroll, true);
}
return () => {
if (textarea) {
textarea.removeEventListener("scroll", handleScroll);
}
window.removeEventListener("scroll", handleScroll, true);
};
}, [showFloatingToolbar, getCursorPosition, getActiveFormats]);
// Авторасширение textarea
React.useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
const autoExpand = () => {
textarea.style.height = "auto";
textarea.style.height = textarea.scrollHeight + "px";
};
textarea.addEventListener("input", autoExpand);
autoExpand();
return () => {
textarea.removeEventListener("input", autoExpand);
};
}, [content]);
const imageInputRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
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 + images.length > 10) {
showNotification("Можно загрузить максимум 10 изображений", "warning");
return;
}
setImages([...images, ...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
);
setFiles([...files, ...newFiles]);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
return (
<div className="main">
<MarkdownToolbar
onInsert={insertMarkdown}
onImageClick={handleImageButtonClick}
onFileClick={handleFileButtonClick}
onInsertColor={insertColorMarkdown}
/>
<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}
/>
{!isPreviewMode && (
<>
<textarea
ref={textareaRef}
className="textInput"
id="noteInput"
placeholder="Ваша заметка..."
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
onContextMenu={(e) => {
// Предотвращаем появление контекстного меню браузера при выделении текста
// Но разрешаем его, если редактор пустой (чтобы можно было вставить текст)
const textarea = textareaRef.current;
if (textarea) {
const hasText = textarea.value.trim().length > 0;
const hasSelection =
textarea.selectionStart !== textarea.selectionEnd;
// Блокируем браузерное меню только если есть текст И есть выделение
if (hasText && hasSelection) {
e.preventDefault();
}
}
}}
/>
{floatingToolbarEnabled && (
<FloatingToolbar
textareaRef={textareaRef}
onFormat={insertMarkdown}
visible={showFloatingToolbar}
position={toolbarPosition}
onHide={() => setShowFloatingToolbar(false)}
onInsertColor={insertColorMarkdown}
activeFormats={activeFormats}
hasSelection={hasSelection}
/>
)}
</>
)}
{isPreviewMode && <NotePreview content={content} />}
<ImageUpload images={images} onChange={setImages} />
<FileUpload files={files} onChange={setFiles} />
<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={handleSave}>
Сохранить
</button>
</div>
<span className="save-hint">или нажмите Alt + Enter</span>
</div>
</div>
);
};