1014 lines
38 KiB
TypeScript
1014 lines
38 KiB
TypeScript
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>
|
||
);
|
||
};
|