NoteJS/public/app.js
Fovway 49834d4ef4 Добавлена поддержка загрузки и управления файлами в заметках
- Реализована возможность загрузки файлов (PDF, DOC, XLS и др.) к заметкам с помощью нового API.
- Добавлены функции для получения, удаления и отображения прикрепленных файлов в интерфейсе заметок.
- Обновлены стили и обработчики событий для работы с файлами в режиме редактирования и создания заметок.
- Улучшена логика обработки файлов, включая проверку на дубликаты и ограничения по размеру.
2025-10-29 00:07:15 +07:00

4887 lines
179 KiB
JavaScript
Raw Permalink 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.

// Кэширование аватарки
const AVATAR_CACHE_KEY = "avatar_cache";
const AVATAR_TIMESTAMP_KEY = "avatar_timestamp";
// Система стека уведомлений
let notificationStack = [];
// Универсальная функция для показа уведомлений
function showNotification(message, type = "info") {
// Создаем уведомление
const notification = document.createElement("div");
notification.className = `notification notification-${type}`;
notification.textContent = message;
notification.id = `notification-${Date.now()}-${Math.random()
.toString(36)
.substr(2, 9)}`;
// Добавляем возможность закрытия по клику
notification.style.cursor = "pointer";
notification.addEventListener("click", () => {
removeNotification(notification);
});
// Добавляем на страницу
document.body.appendChild(notification);
// Добавляем в стек
notificationStack.push(notification);
// Обновляем позиции всех уведомлений
updateNotificationPositions();
// Анимация появления
setTimeout(() => {
if (window.innerWidth <= 768) {
// На мобильных устройствах используем только translateY
notification.style.transform = "translateY(0)";
} else {
// На десктопе используем translateX(-50%) translateY(0)
notification.style.transform = "translateX(-50%) translateY(0)";
}
}, 100);
// Автоматическое удаление через 4 секунды
setTimeout(() => {
removeNotification(notification);
}, 4000);
}
// Функция для удаления уведомления
function removeNotification(notification) {
// Анимация исчезновения
if (window.innerWidth <= 768) {
// На мобильных устройствах используем только translateY
notification.style.transform = "translateY(-100%)";
} else {
// На десктопе используем translateX(-50%) translateY(-100%)
notification.style.transform = "translateX(-50%) translateY(-100%)";
}
setTimeout(() => {
// Удаляем из DOM
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
// Удаляем из стека
const index = notificationStack.indexOf(notification);
if (index > -1) {
notificationStack.splice(index, 1);
}
// Обновляем позиции оставшихся уведомлений
updateNotificationPositions();
}, 300);
}
// Функция для обновления позиций уведомлений
function updateNotificationPositions() {
notificationStack.forEach((notification, index) => {
const offset = index * 70; // 70px между уведомлениями
notification.style.top = `${20 + offset}px`;
// Обновляем transform в зависимости от размера экрана
if (window.innerWidth <= 768) {
// На мобильных устройствах используем только translateY
notification.style.transform = "translateY(0)";
} else {
// На десктопе используем translateX(-50%) translateY(0)
notification.style.transform = "translateX(-50%) translateY(0)";
}
});
}
// Обработчик изменения размера окна
window.addEventListener("resize", () => {
updateNotificationPositions();
});
// Тестовая функция для демонстрации стека уведомлений (можно удалить в продакшене)
function testNotificationStack() {
showNotification("Первое уведомление", "success");
setTimeout(() => showNotification("Второе уведомление", "info"), 500);
setTimeout(() => showNotification("Третье уведомление", "warning"), 1000);
setTimeout(() => showNotification("Четвертое уведомление", "error"), 1500);
}
// Тестовая функция для демонстрации модальных окон (можно удалить в продакшене)
async function testModalWindows() {
// Тест обычного модального окна
const result1 = await showConfirmModal(
"Тест обычного окна",
"Это тестовое модальное окно с обычной кнопкой",
{ confirmText: "Подтвердить" }
);
console.log("Результат обычного окна:", result1);
// Тест опасного модального окна
const result2 = await showConfirmModal(
"Тест опасного окна",
"Это тестовое модальное окно с красной кнопкой",
{ confirmType: "danger", confirmText: "Удалить" }
);
console.log("Результат опасного окна:", result2);
}
// Универсальная функция для модальных окон подтверждения
function showConfirmModal(title, message, options = {}) {
return new Promise((resolve) => {
// Создаем модальное окно
const modal = document.createElement("div");
modal.className = "modal";
modal.style.display = "block";
// Создаем содержимое модального окна
const modalContent = document.createElement("div");
modalContent.className = "modal-content";
// Создаем заголовок
const modalHeader = document.createElement("div");
modalHeader.className = "modal-header";
modalHeader.innerHTML = `
<h3>${title}</h3>
<span class="modal-close">&times;</span>
`;
// Создаем тело модального окна
const modalBody = document.createElement("div");
modalBody.className = "modal-body";
modalBody.innerHTML = `<p>${message}</p>`;
// Создаем футер с кнопками
const modalFooter = document.createElement("div");
modalFooter.className = "modal-footer";
modalFooter.innerHTML = `
<button id="confirmBtn" class="${
options.confirmType === "danger" ? "btn-danger" : "btn-primary"
}" style="margin-right: 10px">
${options.confirmText || "OK"}
</button>
<button id="cancelBtn" class="btn-secondary">
${options.cancelText || "Отмена"}
</button>
`;
// Собираем модальное окно
modalContent.appendChild(modalHeader);
modalContent.appendChild(modalBody);
modalContent.appendChild(modalFooter);
modal.appendChild(modalContent);
// Добавляем на страницу
document.body.appendChild(modal);
// Функция закрытия
function closeModal() {
modal.style.display = "none";
if (modal.parentNode) {
modal.parentNode.removeChild(modal);
}
}
// Обработчики событий
const closeBtn = modalHeader.querySelector(".modal-close");
const cancelBtn = modalFooter.querySelector("#cancelBtn");
const confirmBtn = modalFooter.querySelector("#confirmBtn");
closeBtn.addEventListener("click", () => {
closeModal();
resolve(false);
});
cancelBtn.addEventListener("click", () => {
closeModal();
resolve(false);
});
confirmBtn.addEventListener("click", () => {
closeModal();
resolve(true);
});
// Закрытие при клике вне модального окна
modal.addEventListener("click", (e) => {
if (e.target === modal) {
closeModal();
resolve(false);
}
});
// Закрытие по Escape
const handleEscape = (e) => {
if (e.key === "Escape") {
closeModal();
resolve(false);
document.removeEventListener("keydown", handleEscape);
}
};
document.addEventListener("keydown", handleEscape);
});
}
// Функция для кэширования аватарки
async function cacheAvatar(avatarUrl) {
try {
const response = await fetch(avatarUrl);
if (!response.ok) return false;
const blob = await response.blob();
const base64 = await blobToBase64(blob);
const cacheData = {
base64: base64,
timestamp: Date.now(),
url: avatarUrl,
};
localStorage.setItem(AVATAR_CACHE_KEY, JSON.stringify(cacheData));
localStorage.setItem(AVATAR_TIMESTAMP_KEY, Date.now().toString());
return true;
} catch (error) {
console.error("Ошибка кэширования аватарки:", error);
return false;
}
}
// Функция для получения аватарки из кэша
function getCachedAvatar() {
try {
const cacheData = localStorage.getItem(AVATAR_CACHE_KEY);
const timestamp = localStorage.getItem(AVATAR_TIMESTAMP_KEY);
if (!cacheData || !timestamp) return null;
const data = JSON.parse(cacheData);
const cacheAge = Date.now() - parseInt(timestamp);
// Кэш действителен 24 часа (86400000 мс)
if (cacheAge > 24 * 60 * 60 * 1000) {
clearAvatarCache();
return null;
}
return data;
} catch (error) {
console.error("Ошибка получения аватарки из кэша:", error);
clearAvatarCache();
return null;
}
}
// Функция для очистки кэша аватарки
function clearAvatarCache() {
localStorage.removeItem(AVATAR_CACHE_KEY);
localStorage.removeItem(AVATAR_TIMESTAMP_KEY);
}
// Преобразование Blob в base64
function blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
// DOM элементы
const noteInput = document.getElementById("noteInput");
const saveBtn = document.getElementById("saveBtn");
const notesList = document.getElementById("notes-container");
// Получаем кнопки markdown
const boldBtn = document.getElementById("boldBtn");
const italicBtn = document.getElementById("italicBtn");
const strikethroughBtn = document.getElementById("strikethroughBtn");
const colorBtn = document.getElementById("colorBtn");
const headerBtn = document.getElementById("headerBtn");
const headerDropdown = document.getElementById("headerDropdown");
const listBtn = document.getElementById("listBtn");
const numberedListBtn = document.getElementById("numberedListBtn");
const quoteBtn = document.getElementById("quoteBtn");
const codeBtn = document.getElementById("codeBtn");
const linkBtn = document.getElementById("linkBtn");
const checkboxBtn = document.getElementById("checkboxBtn");
const imageBtn = document.getElementById("imageBtn");
const spoilerBtn = document.getElementById("spoilerBtn");
const previewBtn = document.getElementById("previewBtn");
const aiImproveBtn = document.getElementById("aiImproveBtn");
// Кнопка настроек
const settingsBtn = document.getElementById("settings-btn");
// Элементы для загрузки изображений
const imageInput = document.getElementById("imageInput");
const imagePreviewContainer = document.getElementById("imagePreviewContainer");
const imagePreviewList = document.getElementById("imagePreviewList");
const clearImagesBtn = document.getElementById("clearImagesBtn");
// Элементы для загрузки файлов
const fileBtn = document.getElementById("fileBtn");
const fileInput = document.getElementById("fileInput");
const filePreviewContainer = document.getElementById("filePreviewContainer");
const filePreviewList = document.getElementById("filePreviewList");
const clearFilesBtn = document.getElementById("clearFilesBtn");
// Элементы для предпросмотра заметки
const notePreviewContainer = document.getElementById("notePreviewContainer");
const notePreviewContent = document.getElementById("notePreviewContent");
// Модальное окно для просмотра изображений
const imageModal = document.getElementById("imageModal");
const modalImage = document.getElementById("modalImage");
const modalClose = document.querySelector(".image-modal-close");
// Массив для хранения выбранных изображений
let selectedImages = [];
let selectedFiles = [];
// Флаг режима предпросмотра
let isPreviewMode = false;
// Глобальные переменные для заметок и фильтрации
let allNotes = [];
let selectedDateFilter = null;
let selectedTagFilter = null;
let searchQuery = "";
let searchResults = [];
let notesCache = null; // Кэш для заметок
let lastLoadTime = 0; // Время последней загрузки
// Lazy loading для изображений
function initLazyLoading() {
// Проверяем поддержку Intersection Observer API
if ("IntersectionObserver" in window) {
const imageObserver = new IntersectionObserver(
(entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
// Если у изображения есть data-src, загружаем его
if (img.dataset.src) {
img.src = img.dataset.src;
img.removeAttribute("data-src");
}
img.classList.remove("lazy");
observer.unobserve(img);
}
});
},
{
rootMargin: "50px 0px", // Загружать изображения за 50px до появления в viewport
threshold: 0.01,
}
);
// Наблюдаем за всеми изображениями с классом lazy
document.querySelectorAll("img.lazy").forEach((img) => {
imageObserver.observe(img);
});
} else {
// Fallback для старых браузеров - просто показываем все изображения
document.querySelectorAll("img.lazy").forEach((img) => {
if (img.dataset.src) {
img.src = img.dataset.src;
img.removeAttribute("data-src");
}
img.classList.remove("lazy");
});
}
}
// Функция для получения текущей даты и времени
function getFormattedDateTime() {
let now = new Date();
let day = String(now.getDate()).padStart(2, "0");
let month = String(now.getMonth() + 1).padStart(2, "0");
let year = now.getFullYear();
let hours = String(now.getHours()).padStart(2, "0");
let minutes = String(now.getMinutes()).padStart(2, "0");
return {
date: `${day}.${month}.${year}`,
time: `${hours}:${minutes}`,
};
}
// Вспомогательные функции для корректной работы с временем (UTC -> локаль)
function parseSQLiteUtc(ts) {
return new Date(ts.replace(" ", "T") + "Z");
}
function formatLocalDateTime(date) {
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
function formatLocalDateOnly(date) {
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(date);
}
// Функция для авторасширения текстового поля
function autoExpandTextarea(textarea) {
textarea.style.height = "auto";
textarea.style.height = textarea.scrollHeight + "px";
}
// Функция для извлечения тегов из текста заметки
function extractTags(content) {
const tagRegex = /#([а-яё\w]+)/gi;
const tags = [];
let match;
while ((match = tagRegex.exec(content)) !== null) {
const matchIndex = match.index;
// Проверяем, не находится ли # внутри HTML-атрибута
const beforeContext = content.substring(
Math.max(0, matchIndex - 100),
matchIndex
);
const afterContext = content.substring(
matchIndex + match[0].length,
Math.min(content.length, matchIndex + match[0].length + 100)
);
// Проверяем, есть ли признаки HTML-атрибута (например, style="color: #...)
const lastOpenTag = beforeContext.lastIndexOf("<");
const lastCloseTag = beforeContext.lastIndexOf(">");
// Если внутри HTML-тега, пропускаем
if (lastOpenTag > lastCloseTag) {
continue;
}
// Проверяем наличие = и кавычки перед #
const lastQuote = Math.max(
beforeContext.lastIndexOf('"'),
beforeContext.lastIndexOf("'")
);
const lastEquals = beforeContext.lastIndexOf("=");
// Если перед # есть = и кавычка, и после есть закрывающая кавычка
if (lastEquals > -1 && lastQuote > lastEquals) {
const nextQuote = Math.min(
afterContext.indexOf('"') !== -1 ? afterContext.indexOf('"') : Infinity,
afterContext.indexOf("'") !== -1 ? afterContext.indexOf("'") : Infinity
);
if (nextQuote !== Infinity) {
continue; // Пропускаем, это часть HTML-атрибута
}
}
const tag = match[1].toLowerCase();
if (!tags.includes(tag)) {
tags.push(tag);
}
}
return tags;
}
// Функция для преобразования тегов в заметках в кликабельные элементы
function makeTagsClickable(content) {
// Сначала находим все теги, которые еще не обернуты в HTML
const tagRegex = /#([а-яё\w]+)/gi;
let result = content;
let match;
// Создаем массив всех совпадений с их позициями
const matches = [];
while ((match = tagRegex.exec(content)) !== null) {
matches.push({
fullMatch: match[0],
tag: match[1],
index: match.index,
});
}
// Обрабатываем совпадения в обратном порядке, чтобы не сбить индексы
for (let i = matches.length - 1; i >= 0; i--) {
const match = matches[i];
const beforeTag = result.substring(0, match.index);
const afterTag = result.substring(match.index + match.fullMatch.length);
// Проверяем, не находится ли тег уже внутри HTML-тега
const lastOpenTag = beforeTag.lastIndexOf("<");
const lastCloseTag = beforeTag.lastIndexOf(">");
// Если последний открывающий тег идет после последнего закрывающего, значит мы внутри HTML-тега
if (lastOpenTag > lastCloseTag) {
continue; // Пропускаем этот тег
}
// Дополнительная проверка: не находится ли # внутри HTML-атрибута (например, style="color: #ff0000")
// Ищем последний символ кавычки перед текущей позицией
const beforeContext = beforeTag.substring(Math.max(0, match.index - 100));
const lastQuote = Math.max(
beforeContext.lastIndexOf('"'),
beforeContext.lastIndexOf("'")
);
const lastEquals = beforeContext.lastIndexOf("=");
// Если перед # есть = и кавычка, и нет закрывающей кавычки после =, то # находится в атрибуте
if (lastEquals > -1 && lastQuote > lastEquals) {
const afterContext = afterTag.substring(
0,
Math.min(100, afterTag.length)
);
const nextQuote = Math.min(
afterContext.indexOf('"') !== -1 ? afterContext.indexOf('"') : Infinity,
afterContext.indexOf("'") !== -1 ? afterContext.indexOf("'") : Infinity
);
// Если нашли закрывающую кавычку, значит # внутри атрибута
if (nextQuote !== Infinity) {
continue; // Пропускаем этот тег
}
}
// Заменяем тег на кликабельный элемент
const replacement = `<span class="tag-in-note" data-tag="${match.tag}">${match.fullMatch}</span>`;
result = beforeTag + replacement + afterTag;
}
return result;
}
// Функция для получения всех уникальных тегов из заметок
function getAllTags(notes) {
const tagCounts = {};
notes.forEach((note) => {
const tags = extractTags(note.content);
tags.forEach((tag) => {
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
});
});
return tagCounts;
}
// Функция для отображения тегов
function renderTags() {
const tagsContainer = document.getElementById("tagsContainer");
if (!tagsContainer) return;
const tagCounts = getAllTags(allNotes);
const sortedTags = Object.keys(tagCounts).sort();
if (sortedTags.length === 0) {
tagsContainer.innerHTML =
'<div style="font-size: 10px; color: #999; text-align: center;">Нет тегов</div>';
return;
}
tagsContainer.innerHTML = sortedTags
.map((tag) => {
const count = tagCounts[tag];
const isActive = selectedTagFilter === tag ? "active" : "";
return `<span class="tag ${isActive}" data-tag="${tag}">#${tag}<span class="tag-count">${count}</span></span>`;
})
.join("");
// Добавляем обработчики кликов для тегов
tagsContainer.querySelectorAll(".tag").forEach((tagElement) => {
tagElement.addEventListener(
"click",
async (event) => await handleTagClick(event)
);
});
}
// Обработчик клика на тег
async function handleTagClick(event) {
const clickedTag = event.target.closest(".tag").dataset.tag;
// Если кликнули на тот же тег, снимаем фильтр
if (selectedTagFilter === clickedTag) {
selectedTagFilter = null;
} else {
selectedTagFilter = clickedTag;
}
// Перерисовываем заметки и теги
await renderNotes(allNotes);
renderTags();
updateFilterIndicator();
}
// Привязываем авторасширение к текстовому полю для создания заметки
noteInput.addEventListener("input", function () {
autoExpandTextarea(noteInput);
});
// Изначально запускаем для установки правильной высоты
autoExpandTextarea(noteInput);
// Функция для вставки цветового тега
function insertColorTag() {
// Создаем диалог выбора цвета
const colorDialog = document.createElement("input");
colorDialog.type = "color";
colorDialog.style.display = "none";
document.body.appendChild(colorDialog);
// Обработчик изменения цвета
colorDialog.addEventListener("change", function () {
const selectedColor = this.value;
insertColorMarkdown(selectedColor);
document.body.removeChild(this);
});
// Обработчик отмены
colorDialog.addEventListener("cancel", function () {
document.body.removeChild(this);
});
// Показываем диалог выбора цвета
colorDialog.click();
}
// Функция для вставки цветового markdown
function insertColorMarkdown(color) {
const start = noteInput.selectionStart;
const end = noteInput.selectionEnd;
const text = noteInput.value;
const before = text.substring(0, start);
const selected = text.substring(start, end);
const after = text.substring(end);
let replacement;
if (selected.trim() === "") {
// Если текст не выделен, вставляем шаблон
replacement = `<span style="color: ${color}">Текст</span>`;
} else {
// Если текст выделен, оборачиваем его в цветовой тег
replacement = `<span style="color: ${color}">${selected}</span>`;
}
noteInput.value = before + replacement + after;
// Устанавливаем курсор после вставленного текста
const cursorPosition = start + replacement.length;
noteInput.setSelectionRange(cursorPosition, cursorPosition);
noteInput.focus();
}
// Функция для вставки markdown
function insertSpoiler() {
const start = noteInput.selectionStart;
const end = noteInput.selectionEnd;
const text = noteInput.value;
const before = text.substring(0, start);
const selected = text.substring(start, end);
const after = text.substring(end);
let newText;
let newCursorPos;
if (selected) {
// Если есть выделенный текст, оборачиваем его в спойлер
newText = before + "||" + selected + "||" + after;
newCursorPos = start + selected.length + 4; // После выделенного текста
} else {
// Если нет выделенного текста, вставляем пустой спойлер
newText = before + "||скрытый текст||" + after;
newCursorPos = start + 2; // Внутри спойлера для редактирования
}
noteInput.value = newText;
noteInput.setSelectionRange(newCursorPos, newCursorPos);
noteInput.focus();
}
function insertMarkdown(tag) {
const start = noteInput.selectionStart;
const end = noteInput.selectionEnd;
const text = noteInput.value;
const before = text.substring(0, start);
const selected = text.substring(start, end);
const after = text.substring(end);
// Мультистрочные преобразования списков (toggle)
if (
(tag === "1. " || tag === "- " || tag === "- [ ] ") &&
selected.includes("\n")
) {
const mode =
tag === "1. " ? "ordered" : tag === "- " ? "unordered" : "todo";
const transformed = transformSelection(noteInput, mode);
noteInput.value = transformed.newValue;
noteInput.setSelectionRange(transformed.newSelStart, transformed.newSelEnd);
noteInput.focus();
return;
}
// Определяем, какие теги оборачивают текст (нуждаются в двойных тегах)
const wrappingTags = ["**", "*", "`"];
const isWrappingTag = wrappingTags.some((wrapTag) => tag.startsWith(wrapTag));
if (isWrappingTag && selected.startsWith(tag) && selected.endsWith(tag)) {
// Если оборачивающие теги уже есть, удаляем их
noteInput.value = `${before}${selected.slice(
tag.length,
-tag.length
)}${after}`;
noteInput.setSelectionRange(start, end - 2 * tag.length);
} else if (!isWrappingTag && selected.startsWith(tag)) {
// Если одинарные теги (заголовки, списки) уже есть, удаляем их
noteInput.value = `${before}${selected.slice(tag.length)}${after}`;
noteInput.setSelectionRange(start, end - tag.length);
} else if (selected.trim() === "") {
// Если текст не выделен
if (tag === "[Текст ссылки](URL)") {
// Для ссылок создаем шаблон с двумя кавычками
noteInput.value = `${before}[Текст ссылки](URL)${after}`;
const cursorPosition = start + 1; // Помещаем курсор внутрь текста ссылки
noteInput.setSelectionRange(cursorPosition, cursorPosition + 12);
} else if (
tag === "- " ||
tag === "1. " ||
tag === "> " ||
/^#{1,6} $/.test(tag) ||
tag === "- [ ] "
) {
// Для списка, цитаты, заголовка и чекбокса помещаем курсор после тега
noteInput.value = `${before}${tag}${after}`;
const cursorPosition = start + tag.length;
noteInput.setSelectionRange(cursorPosition, cursorPosition);
} else {
// Для остальных типов создаем два тега
noteInput.value = `${before}${tag}${tag}${after}`;
const cursorPosition = start + tag.length;
noteInput.setSelectionRange(cursorPosition, cursorPosition);
}
} else {
// Если текст выделен
if (tag === "[Текст ссылки](URL)") {
// Для ссылок используем выделенный текст вместо "Текст ссылки"
noteInput.value = `${before}[${selected}](URL)${after}`;
const cursorPosition = start + selected.length + 3; // Помещаем курсор в URL
noteInput.setSelectionRange(cursorPosition, cursorPosition + 3);
} else if (
tag === "- " ||
tag === "1. " ||
tag === "> " ||
/^#{1,6} $/.test(tag) ||
tag === "- [ ] "
) {
// Для списка, цитаты, заголовка и чекбокса добавляем тег перед выделенным текстом
noteInput.value = `${before}${tag}${selected}${after}`;
const cursorPosition = start + tag.length + selected.length;
noteInput.setSelectionRange(cursorPosition, cursorPosition);
} else {
// Для остальных типов оборачиваем выделенный текст
noteInput.value = `${before}${tag}${selected}${tag}${after}`;
const cursorPosition = start + tag.length + selected.length + tag.length;
noteInput.setSelectionRange(cursorPosition, cursorPosition);
}
}
noteInput.focus();
}
// Функция для вставки цветового тега в режиме редактирования
function insertColorTagForEdit(textarea) {
// Создаем диалог выбора цвета
const colorDialog = document.createElement("input");
colorDialog.type = "color";
colorDialog.style.display = "none";
document.body.appendChild(colorDialog);
// Обработчик изменения цвета
colorDialog.addEventListener("change", function () {
const selectedColor = this.value;
insertColorMarkdownForEdit(textarea, selectedColor);
document.body.removeChild(this);
});
// Обработчик отмены
colorDialog.addEventListener("cancel", function () {
document.body.removeChild(this);
});
// Показываем диалог выбора цвета
colorDialog.click();
}
// Функция для вставки цветового markdown в режиме редактирования
function insertColorMarkdownForEdit(textarea, color) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const before = text.substring(0, start);
const selected = text.substring(start, end);
const after = text.substring(end);
let replacement;
if (selected.trim() === "") {
// Если текст не выделен, вставляем шаблон
replacement = `<span style="color: ${color}">Текст</span>`;
} else {
// Если текст выделен, оборачиваем его в цветовой тег
replacement = `<span style="color: ${color}">${selected}</span>`;
}
textarea.value = before + replacement + after;
// Устанавливаем курсор после вставленного текста
const cursorPosition = start + replacement.length;
textarea.setSelectionRange(cursorPosition, cursorPosition);
textarea.focus();
}
// Функция для вставки markdown в режиме редактирования
function insertMarkdownForEdit(textarea, tag) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const before = text.substring(0, start);
const selected = text.substring(start, end);
const after = text.substring(end);
// Мультистрочные преобразования списков (toggle)
if (
(tag === "1. " || tag === "- " || tag === "- [ ] ") &&
selected.includes("\n")
) {
const mode =
tag === "1. " ? "ordered" : tag === "- " ? "unordered" : "todo";
const transformed = transformSelection(textarea, mode);
textarea.value = transformed.newValue;
textarea.setSelectionRange(transformed.newSelStart, transformed.newSelEnd);
textarea.focus();
return;
}
// Определяем, какие теги оборачивают текст (нуждаются в двойных тегах)
const wrappingTags = ["**", "*", "`"];
const isWrappingTag = wrappingTags.some((wrapTag) => tag.startsWith(wrapTag));
if (isWrappingTag && selected.startsWith(tag) && selected.endsWith(tag)) {
// Если оборачивающие теги уже есть, удаляем их
textarea.value = `${before}${selected.slice(
tag.length,
-tag.length
)}${after}`;
textarea.setSelectionRange(start, end - 2 * tag.length);
} else if (!isWrappingTag && selected.startsWith(tag)) {
// Если одинарные теги (заголовки, списки) уже есть, удаляем их
textarea.value = `${before}${selected.slice(tag.length)}${after}`;
textarea.setSelectionRange(start, end - tag.length);
} else if (selected.trim() === "") {
// Если текст не выделен
if (tag === "[Текст ссылки](URL)") {
// Для ссылок создаем шаблон с двумя кавычками
textarea.value = `${before}[Текст ссылки](URL)${after}`;
const cursorPosition = start + 1; // Помещаем курсор внутрь текста ссылки
textarea.setSelectionRange(cursorPosition, cursorPosition + 12);
} else if (
tag === "- " ||
tag === "1. " ||
tag === "> " ||
/^#{1,6} $/.test(tag) ||
tag === "- [ ] "
) {
// Для списка, цитаты, заголовка и чекбокса помещаем курсор после тега
textarea.value = `${before}${tag}${after}`;
const cursorPosition = start + tag.length;
textarea.setSelectionRange(cursorPosition, cursorPosition);
} else {
// Для остальных типов создаем два тега
textarea.value = `${before}${tag}${tag}${after}`;
const cursorPosition = start + tag.length;
textarea.setSelectionRange(cursorPosition, cursorPosition);
}
} else {
// Если текст выделен
if (tag === "[Текст ссылки](URL)") {
// Для ссылок используем выделенный текст вместо "Текст ссылки"
textarea.value = `${before}[${selected}](URL)${after}`;
const cursorPosition = start + selected.length + 3; // Помещаем курсор в URL
textarea.setSelectionRange(cursorPosition, cursorPosition + 3);
} else if (
tag === "- " ||
tag === "1. " ||
tag === "> " ||
/^#{1,6} $/.test(tag) ||
tag === "- [ ] "
) {
// Для списка, цитаты, заголовка и чекбокса добавляем тег перед выделенным текстом
textarea.value = `${before}${tag}${selected}${after}`;
const cursorPosition = start + tag.length + selected.length;
textarea.setSelectionRange(cursorPosition, cursorPosition);
} else {
// Для остальных типов оборачиваем выделенный текст
textarea.value = `${before}${tag}${selected}${tag}${after}`;
const cursorPosition = start + tag.length + selected.length + tag.length;
textarea.setSelectionRange(cursorPosition, cursorPosition);
}
}
textarea.focus();
}
// Функция для вставки спойлера в режиме редактирования
function insertSpoilerForEdit(textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const before = text.substring(0, start);
const selected = text.substring(start, end);
const after = text.substring(end);
let newText;
let newCursorPos;
if (selected) {
newText = before + "||" + selected + "||" + after;
newCursorPos = start + selected.length + 4;
} else {
newText = before + "||скрытый текст||" + after;
newCursorPos = start + 2;
}
textarea.value = newText;
textarea.setSelectionRange(newCursorPos, newCursorPos);
textarea.focus();
}
// ==================== МУЛЬТИСТРОЧНЫЕ СПИСКИ (TOGGLE) ====================
function transformSelection(textarea, mode) {
const fullText = textarea.value;
const selStart = textarea.selectionStart;
const selEnd = textarea.selectionEnd;
// Расширяем до границ строк
let blockStart = fullText.lastIndexOf("\n", selStart - 1);
blockStart = blockStart === -1 ? 0 : blockStart + 1;
let blockEnd = fullText.indexOf("\n", selEnd);
blockEnd = blockEnd === -1 ? fullText.length : blockEnd;
const block = fullText.substring(blockStart, blockEnd);
const lines = block.split("\n");
const orderedRe = /^(\s*)(\d+)\.\s/;
const unorderedRe = /^(\s*)([-*+])\s/;
const todoRe = /^(\s*)- \[( |x)\] \s?/i;
const anyListPrefixRe = /^(\s*)(- \[(?: |x)\]\s?|[-*+]\s|\d+\.\s)/i;
function stripAnyPrefix(line) {
const m = line.match(anyListPrefixRe);
if (!m) return line;
return line.slice((m[1] + (m[2] || "")).length);
}
function toggleOrdered(inputLines) {
const nonEmpty = inputLines.filter((l) => l.trim() !== "");
const allHave =
nonEmpty.length > 0 && nonEmpty.every((l) => orderedRe.test(l));
if (allHave) {
return inputLines.map((l) => {
if (!l.trim()) return l;
const m = l.match(orderedRe);
if (!m) return l;
return m[1] + l.slice(m[0].length);
});
}
let index = 1;
return inputLines.map((l) => {
if (!l.trim()) return l;
// Снимаем любые префиксы и нумеруем заново
const indent = l.match(/^\s*/)?.[0] || "";
const content = stripAnyPrefix(l.trimStart());
const numbered = `${indent}${index}. ${content}`;
index += 1;
return numbered;
});
}
function toggleUnordered(inputLines) {
const nonEmpty = inputLines.filter((l) => l.trim() !== "");
const allHave =
nonEmpty.length > 0 && nonEmpty.every((l) => unorderedRe.test(l));
if (allHave) {
return inputLines.map((l) => {
if (!l.trim()) return l;
const m = l.match(unorderedRe);
if (!m) return l;
return m[1] + l.slice(m[0].length);
});
}
return inputLines.map((l) => {
if (!l.trim()) return l;
const indent = l.match(/^\s*/)?.[0] || "";
const content = stripAnyPrefix(l.trimStart());
return `${indent}- ${content}`;
});
}
function toggleTodo(inputLines) {
const nonEmpty = inputLines.filter((l) => l.trim() !== "");
const allHave =
nonEmpty.length > 0 && nonEmpty.every((l) => todoRe.test(l));
if (allHave) {
return inputLines.map((l) => {
if (!l.trim()) return l;
const m = l.match(todoRe);
if (!m) return l;
return m[1] + l.slice(m[0].length);
});
}
return inputLines.map((l) => {
if (!l.trim()) return l;
const indent = l.match(/^\s*/)?.[0] || "";
const content = stripAnyPrefix(l.trimStart());
return `${indent}- [ ] ${content}`;
});
}
let newLines;
if (mode === "ordered") newLines = toggleOrdered(lines);
else if (mode === "unordered") newLines = toggleUnordered(lines);
else newLines = toggleTodo(lines);
const newBlock = newLines.join("\n");
const newValue =
fullText.slice(0, blockStart) + newBlock + fullText.slice(blockEnd);
const newSelStart = blockStart;
const newSelEnd = blockStart + newBlock.length;
return { newValue, newSelStart, newSelEnd };
}
// Обработчики для кнопок markdown
boldBtn.addEventListener("click", function () {
insertMarkdown("**");
});
italicBtn.addEventListener("click", function () {
insertMarkdown("*");
});
strikethroughBtn.addEventListener("click", function () {
insertMarkdown("~~");
});
colorBtn.addEventListener("click", function () {
insertColorTag();
});
// Обработчик кнопки заголовка - открываем выпадающее меню
headerBtn.addEventListener("click", function (event) {
event.stopPropagation();
// Проверяем позицию и корректируем если нужно
const rect = headerDropdown.getBoundingClientRect();
const viewportWidth = window.innerWidth;
// Если меню выходит за правую границу, позиционируем его слева
if (rect.right > viewportWidth) {
headerDropdown.style.left = "auto";
headerDropdown.style.right = "0";
} else {
headerDropdown.style.left = "0";
headerDropdown.style.right = "auto";
}
headerDropdown.classList.toggle("show");
});
// Обработчики для пунктов выпадающего меню
headerDropdown.querySelectorAll("button").forEach((btn) => {
btn.addEventListener("click", function (event) {
event.stopPropagation();
const level = this.dataset.level;
const headerTag = "#".repeat(parseInt(level)) + " ";
insertMarkdown(headerTag);
headerDropdown.classList.remove("show");
});
});
// Закрытие выпадающего меню при клике вне его
document.addEventListener("click", function () {
headerDropdown.classList.remove("show");
});
listBtn.addEventListener("click", function () {
insertMarkdown("- ");
});
numberedListBtn.addEventListener("click", function () {
insertMarkdown("1. ");
});
quoteBtn.addEventListener("click", function () {
insertMarkdown("> ");
});
codeBtn.addEventListener("click", function () {
insertMarkdown("`");
});
linkBtn.addEventListener("click", function () {
insertMarkdown("[Текст ссылки](URL)");
});
checkboxBtn.addEventListener("click", function () {
insertMarkdown("- [ ] ");
});
spoilerBtn.addEventListener("click", function () {
insertSpoiler();
});
// Обработчик для кнопки загрузки изображений
imageBtn.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
imageInput.click();
});
// Дополнительный обработчик для touch событий на мобильных устройствах
imageBtn.addEventListener("touchend", function (event) {
event.preventDefault();
event.stopPropagation();
imageInput.click();
});
// Обработчик для кнопки прикрепления файлов
fileBtn.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
fileInput.click();
});
fileBtn.addEventListener("touchend", function (event) {
event.preventDefault();
event.stopPropagation();
fileInput.click();
});
// Обработчик для кнопки предпросмотра
previewBtn.addEventListener("click", function () {
togglePreview();
});
// Обработчик для кнопки улучшения через AI
aiImproveBtn.addEventListener("click", async function () {
const content = noteInput.value.trim();
if (!content) {
showNotification("Введите текст для улучшения", "warning");
return;
}
// Показываем индикатор загрузки
const originalHTML = aiImproveBtn.innerHTML;
const originalTitle = aiImproveBtn.title;
aiImproveBtn.disabled = true;
aiImproveBtn.innerHTML =
'<span class="iconify" data-icon="mdi:loading" style="animation: spin 1s linear infinite;"></span> Обработка...';
aiImproveBtn.title = "Обработка...";
try {
const response = await fetch("/api/ai/improve", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ text: content }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Ошибка улучшения текста");
}
// Заменяем текст на улучшенный
noteInput.value = data.improvedText;
// Авторасширяем textarea
autoExpandTextarea(noteInput);
showNotification("Текст успешно улучшен!", "success");
} catch (error) {
console.error("Ошибка улучшения текста:", error);
showNotification(error.message || "Ошибка улучшения текста", "error");
} finally {
// Восстанавливаем кнопку
aiImproveBtn.disabled = false;
aiImproveBtn.innerHTML = originalHTML;
aiImproveBtn.title = originalTitle;
}
});
// Функция переключения режима предпросмотра
function togglePreview() {
isPreviewMode = !isPreviewMode;
if (isPreviewMode) {
// Показываем предпросмотр
noteInput.style.display = "none";
notePreviewContainer.style.display = "block";
// Получаем содержимое и рендерим его
const content = noteInput.value;
if (content.trim()) {
// Парсим markdown и делаем теги кликабельными
const htmlContent = marked.parse(content);
const contentWithTags = makeTagsClickable(htmlContent);
notePreviewContent.innerHTML = contentWithTags;
// Применяем текущую тему к предпросмотру
applyThemeToPreview();
// Инициализируем lazy loading для изображений в превью
setTimeout(() => {
initLazyLoading();
}, 0);
} else {
notePreviewContent.innerHTML =
'<p style="color: var(--text-muted); font-style: italic;">Нет содержимого для предпросмотра</p>';
}
// Меняем иконку кнопки
previewBtn.innerHTML =
'<span class="iconify" data-icon="mdi:eye-off"></span>';
previewBtn.title = "Закрыть предпросмотр";
} else {
// Возвращаемся к редактированию
noteInput.style.display = "block";
notePreviewContainer.style.display = "none";
// Меняем иконку обратно
previewBtn.innerHTML = '<span class="iconify" data-icon="mdi:eye"></span>';
previewBtn.title = "Предпросмотр";
}
}
// Функция применения темы к предпросмотру
function applyThemeToPreview() {
if (!notePreviewContainer || notePreviewContainer.style.display === "none") {
return;
}
const currentTheme = document.documentElement.getAttribute("data-theme");
// Применяем тему к контейнеру предпросмотра
if (currentTheme === "dark") {
notePreviewContainer.setAttribute("data-theme", "dark");
} else {
notePreviewContainer.removeAttribute("data-theme");
}
// Обновляем стили для элементов внутри предпросмотра
const previewElements = notePreviewContent.querySelectorAll("*");
previewElements.forEach((element) => {
// Применяем тему к элементам кода
if (element.tagName === "CODE" || element.tagName === "PRE") {
if (currentTheme === "dark") {
element.style.backgroundColor = "var(--bg-quaternary)";
element.style.color = "#e6e6e6";
element.style.border = "1px solid var(--border-primary)";
} else {
element.style.backgroundColor = "var(--bg-quaternary)";
element.style.color = "var(--text-primary)";
element.style.border = "1px solid var(--border-primary)";
}
}
// Применяем тему к цитатам
if (element.tagName === "BLOCKQUOTE") {
if (currentTheme === "dark") {
element.style.backgroundColor = "var(--bg-tertiary)";
element.style.borderLeftColor = "var(--accent-color, #4a9eff)";
element.style.color = "var(--text-secondary)";
} else {
element.style.backgroundColor = "var(--bg-tertiary)";
element.style.borderLeftColor = "var(--accent-color, #007bff)";
element.style.color = "var(--text-secondary)";
}
}
});
}
// Функция применения темы к предпросмотру в режиме редактирования
function applyThemeToEditPreview(editPreviewContainer, editPreviewContent) {
if (!editPreviewContainer || editPreviewContainer.style.display === "none") {
return;
}
const currentTheme = document.documentElement.getAttribute("data-theme");
// Применяем тему к контейнеру предпросмотра редактирования
if (currentTheme === "dark") {
editPreviewContainer.setAttribute("data-theme", "dark");
} else {
editPreviewContainer.removeAttribute("data-theme");
}
// Обновляем стили для элементов внутри предпросмотра редактирования
const previewElements = editPreviewContent.querySelectorAll("*");
previewElements.forEach((element) => {
// Применяем тему к элементам кода
if (element.tagName === "CODE" || element.tagName === "PRE") {
if (currentTheme === "dark") {
element.style.backgroundColor = "var(--bg-quaternary)";
element.style.color = "#e6e6e6";
element.style.border = "1px solid var(--border-primary)";
} else {
element.style.backgroundColor = "var(--bg-quaternary)";
element.style.color = "var(--text-primary)";
element.style.border = "1px solid var(--border-primary)";
}
}
// Применяем тему к цитатам
if (element.tagName === "BLOCKQUOTE") {
if (currentTheme === "dark") {
element.style.backgroundColor = "var(--bg-tertiary)";
element.style.borderLeftColor = "var(--accent-color, #4a9eff)";
element.style.color = "var(--text-secondary)";
} else {
element.style.backgroundColor = "var(--bg-tertiary)";
element.style.borderLeftColor = "var(--accent-color, #007bff)";
element.style.color = "var(--text-secondary)";
}
}
});
}
// Обработчик выбора файлов
imageInput.addEventListener("change", function (event) {
const files = Array.from(event.target.files);
let addedCount = 0;
files.forEach((file) => {
if (file.type.startsWith("image/")) {
// Проверяем размер файла (максимум 10MB)
if (file.size > 10 * 1024 * 1024) {
showNotification(
`Файл "${file.name}" слишком большой. Максимальный размер: 10MB`,
"error"
);
return;
}
// Проверяем, не добавлен ли уже этот файл
const isDuplicate = selectedImages.some(
(existingFile) =>
existingFile.name === file.name && existingFile.size === file.size
);
if (!isDuplicate) {
selectedImages.push(file);
addedCount++;
}
} else {
showNotification(`Файл "${file.name}" не является изображением`, "error");
}
});
if (addedCount > 0) {
updateImagePreview();
// Показываем уведомление о добавленных файлах
if (addedCount === 1) {
console.log(`Добавлено 1 изображение`);
} else {
console.log(`Добавлено ${addedCount} изображений`);
}
}
// Очищаем input для возможности повторного выбора тех же файлов
event.target.value = "";
});
// Обработчик очистки всех изображений
clearImagesBtn.addEventListener("click", function () {
selectedImages = [];
updateImagePreview();
imageInput.value = "";
});
// Обработчик для загрузки файлов
fileInput.addEventListener("change", function (event) {
const files = Array.from(event.target.files);
let addedCount = 0;
files.forEach((file) => {
// Проверяем размер файла (максимум 50MB)
if (file.size > 50 * 1024 * 1024) {
showNotification(
`Файл "${file.name}" слишком большой. Максимальный размер: 50MB`,
"error"
);
return;
}
// Проверяем, не добавлен ли уже файл с таким именем и размером
const isDuplicate = selectedFiles.some(
(existingFile) =>
existingFile.name === file.name && existingFile.size === file.size
);
if (!isDuplicate) {
selectedFiles.push(file);
addedCount++;
}
});
if (addedCount > 0) {
updateFilePreview();
showNotification(`Добавлено файлов: ${addedCount}`, "success");
}
fileInput.value = "";
});
// Обработчик очистки всех файлов
clearFilesBtn.addEventListener("click", function () {
selectedFiles = [];
updateFilePreview();
fileInput.value = "";
});
// Обработчики модального окна
modalClose.addEventListener("click", function () {
imageModal.style.display = "none";
});
imageModal.addEventListener("click", function (event) {
if (event.target === imageModal) {
imageModal.style.display = "none";
}
});
// Закрытие модального окна по Escape
document.addEventListener("keydown", function (event) {
if (event.key === "Escape" && imageModal.style.display === "block") {
imageModal.style.display = "none";
}
});
// Функция для обновления превью изображений
function updateImagePreview() {
if (selectedImages.length === 0) {
imagePreviewContainer.style.display = "none";
return;
}
imagePreviewContainer.style.display = "block";
imagePreviewList.innerHTML = "";
selectedImages.forEach((file, index) => {
const reader = new FileReader();
reader.onload = function (e) {
const previewItem = document.createElement("div");
previewItem.className = "image-preview-item";
// Форматируем размер файла
const fileSize = (file.size / 1024 / 1024).toFixed(2);
const fileName =
file.name.length > 20 ? file.name.substring(0, 20) + "..." : file.name;
previewItem.innerHTML = `
<img src="${e.target.result}" alt="Preview" loading="lazy">
<button class="remove-image-btn" data-index="${index}" title="Удалить изображение">×</button>
<div class="image-info">${fileName}<br>${fileSize} MB</div>
`;
imagePreviewList.appendChild(previewItem);
// Обработчик удаления изображения
const removeBtn = previewItem.querySelector(".remove-image-btn");
removeBtn.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
selectedImages.splice(index, 1);
updateImagePreview();
});
// Дополнительный обработчик для touch событий
removeBtn.addEventListener("touchend", function (event) {
event.preventDefault();
event.stopPropagation();
selectedImages.splice(index, 1);
updateImagePreview();
});
};
reader.onerror = function () {
console.error("Ошибка чтения файла:", file.name);
showNotification(`Ошибка чтения файла: ${file.name}`, "error");
};
reader.readAsDataURL(file);
});
}
// Функция для обновления превью файлов
function updateFilePreview() {
if (selectedFiles.length === 0) {
filePreviewContainer.style.display = "none";
return;
}
filePreviewContainer.style.display = "block";
filePreviewList.innerHTML = "";
selectedFiles.forEach((file, index) => {
const previewItem = document.createElement("div");
previewItem.className = "file-preview-item";
// Форматируем размер файла
const fileSize = (file.size / 1024 / 1024).toFixed(2);
const fileName =
file.name.length > 30 ? file.name.substring(0, 30) + "..." : file.name;
// Определяем иконку по расширению файла
const ext = file.name.split(".").pop().toLowerCase();
let icon = "mdi:file";
if (ext === "pdf") icon = "mdi:file-pdf";
else if (["doc", "docx"].includes(ext)) icon = "mdi:file-word";
else if (["xls", "xlsx"].includes(ext)) icon = "mdi:file-excel";
else if (ext === "txt") icon = "mdi:file-document";
else if (["zip", "rar", "7z"].includes(ext)) icon = "mdi:folder-zip";
previewItem.innerHTML = `
<div class="file-icon">
<span class="iconify" data-icon="${icon}"></span>
</div>
<div class="file-info">
<div class="file-name">${fileName}</div>
<div class="file-size">${fileSize} MB</div>
</div>
<button class="remove-file-btn" data-index="${index}" title="Удалить файл">×</button>
`;
filePreviewList.appendChild(previewItem);
// Обработчик удаления файла
const removeBtn = previewItem.querySelector(".remove-file-btn");
removeBtn.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
selectedFiles.splice(index, 1);
updateFilePreview();
});
removeBtn.addEventListener("touchend", function (event) {
event.preventDefault();
event.stopPropagation();
selectedFiles.splice(index, 1);
updateFilePreview();
});
});
}
// Функция для отображения изображения в модальном окне
function showImageModal(imageSrc) {
console.log("showImageModal called with:", imageSrc);
try {
modalImage.src = imageSrc;
imageModal.style.display = "block";
console.log("Modal opened successfully");
} catch (error) {
console.error("Error in showImageModal:", error);
}
}
// Функция для загрузки изображений на сервер
async function uploadImages(noteId) {
if (selectedImages.length === 0) {
return [];
}
const formData = new FormData();
selectedImages.forEach((file) => {
formData.append("images", file);
});
try {
// Показываем индикатор загрузки для мобильных устройств
const isMobile =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
) ||
(navigator.maxTouchPoints && navigator.maxTouchPoints > 2) ||
window.matchMedia("(max-width: 768px)").matches;
if (isMobile) {
// Создаем простое уведомление о загрузке
const loadingDiv = document.createElement("div");
loadingDiv.id = "mobile-upload-loading";
loadingDiv.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 20px;
border-radius: 10px;
z-index: 10000;
font-size: 16px;
text-align: center;
`;
loadingDiv.innerHTML = `
<div>📤 Загрузка изображений...</div>
<div style="font-size: 12px; margin-top: 10px;">${selectedImages.length} файл(ов)</div>
`;
document.body.appendChild(loadingDiv);
}
const response = await fetch(`/api/notes/${noteId}/images`, {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error("Ошибка загрузки изображений");
}
const result = await response.json();
// Удаляем индикатор загрузки
const loadingDiv = document.getElementById("mobile-upload-loading");
if (loadingDiv) {
loadingDiv.remove();
}
return result.images || [];
} catch (error) {
console.error("Ошибка загрузки изображений:", error);
// Удаляем индикатор загрузки в случае ошибки
const loadingDiv = document.getElementById("mobile-upload-loading");
if (loadingDiv) {
loadingDiv.remove();
}
// Показываем ошибку пользователю
showNotification(`Ошибка загрузки изображений: ${error.message}`, "error");
return [];
}
}
// Функция для получения изображений заметки
async function getNoteImages(noteId) {
try {
const response = await fetch(`/api/notes/${noteId}/images`);
if (!response.ok) {
throw new Error("Ошибка получения изображений");
}
return await response.json();
} catch (error) {
console.error("Ошибка получения изображений:", error);
return [];
}
}
// Функция для загрузки файлов на сервер
async function uploadFiles(noteId) {
if (selectedFiles.length === 0) {
return [];
}
const formData = new FormData();
selectedFiles.forEach((file) => {
formData.append("files", file);
});
try {
const isMobile =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
) ||
(navigator.maxTouchPoints && navigator.maxTouchPoints > 2) ||
window.matchMedia("(max-width: 768px)").matches;
if (isMobile) {
const loadingDiv = document.createElement("div");
loadingDiv.id = "mobile-file-upload-loading";
loadingDiv.style.cssText = `
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.8); color: white; padding: 20px;
border-radius: 10px; z-index: 10000; font-size: 16px; text-align: center;
`;
loadingDiv.innerHTML = `
<div>📎 Загрузка файлов...</div>
<div style="font-size: 12px; margin-top: 10px;">${selectedFiles.length} файл(ов)</div>
`;
document.body.appendChild(loadingDiv);
}
const response = await fetch(`/api/notes/${noteId}/files`, {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error("Ошибка загрузки файлов");
}
const result = await response.json();
const loadingDiv = document.getElementById("mobile-file-upload-loading");
if (loadingDiv) {
loadingDiv.remove();
}
return result.files || [];
} catch (error) {
console.error("Ошибка загрузки файлов:", error);
const loadingDiv = document.getElementById("mobile-file-upload-loading");
if (loadingDiv) {
loadingDiv.remove();
}
showNotification(`Ошибка загрузки файлов: ${error.message}`, "error");
return [];
}
}
// Функция для удаления изображения заметки
async function deleteNoteImage(noteId, imageId) {
try {
const response = await fetch(`/api/notes/${noteId}/images/${imageId}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error("Ошибка удаления изображения");
}
return true;
} catch (error) {
console.error("Ошибка удаления изображения:", error);
return false;
}
}
// Функция для удаления файла заметки
async function deleteNoteFile(noteId, fileId) {
try {
const response = await fetch(`/api/notes/${noteId}/files/${fileId}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error("Ошибка удаления файла");
}
return true;
} catch (error) {
console.error("Ошибка удаления файла:", error);
return false;
}
}
// Функция для загрузки заметок с сервера
async function loadNotes(forceReload = false) {
const now = Date.now();
const CACHE_DURATION = 30000; // 30 секунд кэширования
// Используем кэш, если он не устарел и не требуется принудительная перезагрузка
if (!forceReload && notesCache && now - lastLoadTime < CACHE_DURATION) {
allNotes = notesCache;
await renderNotes(notesCache);
renderCalendar();
renderTags();
renderCalendarMobile();
renderTagsMobile();
return;
}
// Показываем индикатор загрузки
showLoadingIndicator();
try {
const response = await fetch("/api/notes");
if (!response.ok) {
throw new Error("Ошибка загрузки заметок");
}
const notes = await response.json();
allNotes = notes; // Сохраняем все заметки в глобальную переменную
notesCache = notes; // Сохраняем в кэш
lastLoadTime = now;
await renderNotes(notes);
renderCalendar(); // Обновляем календарь после загрузки заметок
renderTags(); // Обновляем теги после загрузки заметок
renderCalendarMobile(); // Обновляем мобильный календарь после загрузки заметок
renderTagsMobile(); // Обновляем мобильные теги после загрузки заметок
} catch (error) {
console.error("Ошибка:", error);
notesList.innerHTML = "<p>Ошибка загрузки заметок</p>";
} finally {
// Скрываем индикатор загрузки
hideLoadingIndicator();
}
}
// Функция для показа индикатора загрузки
function showLoadingIndicator() {
if (!document.getElementById("loading-indicator")) {
const loadingDiv = document.createElement("div");
loadingDiv.id = "loading-indicator";
loadingDiv.innerHTML = `
<div style="
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 20px;
border-radius: 10px;
z-index: 10000;
font-size: 16px;
">
<div style="text-align: center;">
<div style="
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 10px;
"></div>
Загрузка заметок...
</div>
</div>
<style>
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
`;
document.body.appendChild(loadingDiv);
}
}
// Функция для скрытия индикатора загрузки
function hideLoadingIndicator() {
const loadingIndicator = document.getElementById("loading-indicator");
if (loadingIndicator) {
loadingIndicator.remove();
}
}
// Функция для поиска заметок
async function searchNotes(query) {
if (!query || query.trim() === "") {
searchQuery = "";
searchResults = [];
await renderNotes(allNotes);
return;
}
try {
const params = new URLSearchParams();
params.append("q", query.trim());
// Добавляем фильтры, если они активны
if (selectedTagFilter) {
params.append("tag", selectedTagFilter);
}
if (selectedDateFilter) {
params.append("date", selectedDateFilter);
}
const response = await fetch(`/api/notes/search?${params}`);
if (!response.ok) {
throw new Error("Ошибка поиска заметок");
}
searchResults = await response.json();
searchQuery = query.trim();
await renderNotes(searchResults);
} catch (error) {
console.error("Ошибка поиска:", error);
searchResults = [];
await renderNotes(allNotes);
}
}
// Функция для подсветки найденного текста
function highlightSearchText(content, query) {
if (!query || query.trim() === "") {
return content;
}
const regex = new RegExp(
`(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`,
"gi"
);
return content.replace(regex, '<span class="search-highlight">$1</span>');
}
// Настройка marked.js для поддержки чекбоксов и strikethrough
const renderer = new marked.Renderer();
// Функция для определения внешних ссылок
function isExternalLink(href) {
try {
const url = new URL(href);
return url.origin !== window.location.origin;
} catch (e) {
// Если URL невалидный, считаем его внутренним
return false;
}
}
// Переопределяем рендеринг ссылок для открытия внешних ссылок в браузере
const originalLink = renderer.link.bind(renderer);
renderer.link = function (href, title, text) {
const isExternal = isExternalLink(href);
if (isExternal) {
// Внешние ссылки открываем в браузере
return `<a href="${href}" title="${
title || ""
}" target="_blank" rel="noopener noreferrer" class="external-link">${text}</a>`;
} else {
// Внутренние ссылки обрабатываем как обычно
return originalLink(href, title, text);
}
};
// Переопределяем рендеринг списков, чтобы чекбоксы были кликабельными (без disabled)
const originalListItem = renderer.listitem.bind(renderer);
renderer.listitem = function (text, task, checked) {
if (task) {
// Удаляем disabled чекбокс из текста, если он есть
let cleanText = text.replace(/<input[^>]*disabled[^>]*>/gi, "").trim();
// Создаем чекбокс БЕЗ disabled атрибута
return `<li class="task-list-item"><input type="checkbox" ${
checked ? "checked" : ""
}> ${cleanText}</li>\n`;
}
return originalListItem(text, task, checked);
};
// Кастомное расширение для скрытого текста (спойлеров)
const spoilerExtension = {
name: "spoiler",
level: "inline",
start(src) {
return src.match(/\|\|/) ? src.indexOf("||") : -1;
},
tokenizer(src, tokens) {
const rule = /^\|\|(.*?)\|\|/;
const match = rule.exec(src);
if (match) {
return {
type: "spoiler",
raw: match[0],
text: match[1].trim(),
};
}
},
renderer(token) {
return `<span class="spoiler" title="Нажмите, чтобы показать">${token.text}</span>`;
},
};
// Регистрируем расширение через marked.use()
marked.use({ extensions: [spoilerExtension] });
marked.setOptions({
gfm: true, // GitHub Flavored Markdown (включает strikethrough)
breaks: true,
renderer: renderer,
html: true, // Разрешить HTML теги
});
// Функция для отображения заметок
async function renderNotes(notes) {
notesList.innerHTML = "";
// Фильтруем заметки по дате и тегам
let notesToDisplay = notes;
if (selectedDateFilter) {
notesToDisplay = notesToDisplay.filter((note) => {
if (note.created_at) {
return formatDateFromTimestamp(note.created_at) === selectedDateFilter;
}
return false;
});
}
if (selectedTagFilter) {
notesToDisplay = notesToDisplay.filter((note) => {
const tags = extractTags(note.content);
return tags.includes(selectedTagFilter);
});
}
// Если нет заметок для отображения
if (notesToDisplay.length === 0) {
let message = "Заметок пока нет";
if (selectedDateFilter && selectedTagFilter) {
message = `Нет заметок за ${selectedDateFilter} с тегом #${selectedTagFilter}`;
} else if (selectedDateFilter) {
message = `Нет заметок за выбранную дату (${selectedDateFilter})`;
} else if (selectedTagFilter) {
message = `Нет заметок с тегом #${selectedTagFilter}`;
}
notesList.innerHTML = `<div class="container"><p style="text-align: center; color: #999;">${message}</p></div>`;
return;
}
// Итерируемся по заметкам в обычном порядке, чтобы новые были сверху
for (const note of notesToDisplay) {
let contentToProcess = note.content;
// Сначала подсвечиваем найденный текст в исходном markdown
if (searchQuery) {
contentToProcess = highlightSearchText(contentToProcess, searchQuery);
}
// Затем преобразуем теги в кликабельные элементы
const contentWithClickableTags = makeTagsClickable(contentToProcess);
const parsedContent = marked.parse(contentWithClickableTags);
// Используем изображения, которые уже пришли с заметкой
const noteImages = Array.isArray(note.images) ? note.images : [];
let imagesHtml = "";
if (noteImages.length > 0) {
imagesHtml = '<div class="note-images-container">';
noteImages.forEach((image) => {
imagesHtml += `
<div class="note-image-item">
<img src="${image.file_path}" alt="${image.original_name}" class="note-image" data-image-src="${image.file_path}" data-image-id="${image.id}" loading="lazy">
</div>
`;
});
imagesHtml += "</div>";
}
// Используем файлы, которые уже пришли с заметкой
const noteFiles = Array.isArray(note.files) ? note.files : [];
let filesHtml = "";
if (noteFiles.length > 0) {
filesHtml = '<div class="note-files-container">';
noteFiles.forEach((file) => {
const ext = file.original_name.split(".").pop().toLowerCase();
let icon = "mdi:file";
if (ext === "pdf") icon = "mdi:file-pdf";
else if (["doc", "docx"].includes(ext)) icon = "mdi:file-word";
else if (["xls", "xlsx"].includes(ext)) icon = "mdi:file-excel";
else if (ext === "txt") icon = "mdi:file-document";
else if (["zip", "rar", "7z"].includes(ext)) icon = "mdi:folder-zip";
const fileSize = (file.file_size / 1024 / 1024).toFixed(2);
filesHtml += `
<div class="note-file-item">
<a href="${file.file_path}" download="${file.original_name}" class="note-file-link" data-file-id="${file.id}">
<span class="iconify file-icon" data-icon="${icon}"></span>
<div class="file-info">
<div class="file-name">${file.original_name}</div>
<div class="file-size">${fileSize} MB</div>
</div>
</a>
</div>
`;
});
filesHtml += "</div>";
}
// Форматируем дату создания и изменения по локали устройства
let dateDisplay;
if (note.created_at) {
const created = parseSQLiteUtc(note.created_at);
if (note.updated_at && note.created_at !== note.updated_at) {
const updated = parseSQLiteUtc(note.updated_at);
dateDisplay = `${formatLocalDateTime(
created
)} <span class="date-separator"> | </span> <span class="iconify" data-icon="mdi:pencil" style="font-size: 12px; margin: 0 2px;"></span> ${formatLocalDateTime(
updated
)}`;
} else {
dateDisplay = formatLocalDateTime(created);
}
} else {
// Фолбэк для старых записей
dateDisplay = `${note.date} ${note.time}`;
}
// Определяем класс для закрепленной заметки
const pinnedClass = note.is_pinned ? " note-pinned" : "";
const pinIndicator = note.is_pinned
? '<span class="pin-indicator"><span class="iconify" data-icon="mdi:pin"></span>Закреплено</span>'
: "";
const noteHtml = `
<div id="note" class="container${pinnedClass}" data-note-id="${note.id}">
<div class="date">
<span class="date-text">${dateDisplay}${pinIndicator}</span>
<div class="note-actions">
<div id="pinBtn" class="notesHeaderBtn" data-id="${
note.id
}" title="${note.is_pinned ? "Открепить" : "Закрепить"}">
<span class="iconify" data-icon="mdi:pin${
note.is_pinned ? "-off" : ""
}"></span>
</div>
<div id="editBtn" class="notesHeaderBtn" data-id="${
note.id
}" title="Редактировать">
<span class="iconify" data-icon="mdi:pencil"></span>
</div>
<div id="archiveBtn" class="notesHeaderBtn" data-id="${
note.id
}" title="В архив">
<span class="iconify" data-icon="mdi:delete"></span>
</div>
</div>
</div>
<div class="textNote" data-original-content="${note.content.replace(
/"/g,
"&quot;"
)}">${parsedContent}</div>
${imagesHtml}
${filesHtml}
</div>
`;
notesList.insertAdjacentHTML("beforeend", noteHtml);
}
// Добавляем обработчики событий для кнопок редактирования и удаления
addNoteEventListeners();
// Добавляем обработчики кликов для тегов в заметках
addTagClickListeners();
// Добавляем обработчики для изображений в заметках
addImageEventListeners();
// Добавляем обработчики для внешних ссылок
addExternalLinkListeners();
// Добавляем обработчики для чекбоксов в заметках
addCheckboxEventListeners();
// Добавляем обработчики для спойлеров
addSpoilerEventListeners();
// Обрабатываем длинные заметки
handleLongNotes();
// Инициализируем lazy loading для новых изображений
initLazyLoading();
}
// Функция для обработки длинных заметок
function handleLongNotes() {
const MAX_HEIGHT = 300; // Максимальная высота в пикселях
document.querySelectorAll(".textNote").forEach((noteElement) => {
// Проверяем высоту контента
const contentHeight = noteElement.scrollHeight;
if (contentHeight > MAX_HEIGHT) {
// Добавляем класс для сворачивания
noteElement.classList.add("collapsed");
// Создаем кнопку "Показать все"
const showMoreBtn = document.createElement("button");
showMoreBtn.classList.add("show-more-btn");
showMoreBtn.textContent = "Показать полностью";
showMoreBtn.setAttribute("data-expanded", "false");
// Вставляем кнопку после заметки
noteElement.parentElement.insertBefore(
showMoreBtn,
noteElement.nextSibling
);
// Обработчик клика на кнопку
showMoreBtn.addEventListener("click", function () {
const isExpanded = this.getAttribute("data-expanded") === "true";
if (isExpanded) {
// Сворачиваем
noteElement.classList.add("collapsed");
this.textContent = "Показать полностью";
this.setAttribute("data-expanded", "false");
} else {
// Разворачиваем
noteElement.classList.remove("collapsed");
this.textContent = "Свернуть";
this.setAttribute("data-expanded", "true");
}
});
}
});
}
// Функция для добавления обработчиков событий к заметкам
function addNoteEventListeners() {
// Обработчик закрепления
document.querySelectorAll("#pinBtn").forEach((btn) => {
btn.addEventListener("click", async function (event) {
const noteId = event.target.closest("#pinBtn").dataset.id;
try {
const response = await fetch(`/api/notes/${noteId}/pin`, {
method: "PUT",
});
if (!response.ok) {
throw new Error("Ошибка изменения закрепления");
}
const result = await response.json();
// Перезагружаем заметки
await loadNotes(true);
} catch (error) {
console.error("Ошибка:", error);
showNotification("Ошибка изменения закрепления", "error");
}
});
});
// Обработчик архивирования
document.querySelectorAll("#archiveBtn").forEach((btn) => {
btn.addEventListener("click", async function (event) {
const noteId = event.target.closest("#archiveBtn").dataset.id;
const confirmed = await showConfirmModal(
"Подтверждение архивирования",
"Архивировать эту заметку? Её можно будет восстановить из настроек.",
{ confirmText: "Архивировать" }
);
if (confirmed) {
try {
const response = await fetch(`/api/notes/${noteId}/archive`, {
method: "PUT",
});
if (!response.ok) {
throw new Error("Ошибка архивирования заметки");
}
// Перезагружаем заметки
await loadNotes(true);
} catch (error) {
console.error("Ошибка:", error);
showNotification("Ошибка архивирования заметки", "error");
}
}
});
});
// Обработчик удаления
document.querySelectorAll("#deleteBtn").forEach((btn) => {
btn.addEventListener("click", async function (event) {
const noteId = event.target.dataset.id;
const confirmed = await showConfirmModal(
"Подтверждение удаления",
"Вы уверены, что хотите удалить эту заметку?",
{ confirmType: "danger", confirmText: "Удалить" }
);
if (confirmed) {
try {
const response = await fetch(`/api/notes/${noteId}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error("Ошибка удаления заметки");
}
// Перезагружаем заметки
await loadNotes(true);
} catch (error) {
console.error("Ошибка:", error);
showNotification("Ошибка удаления заметки", "error");
}
}
});
});
// Обработчик редактирования
document.querySelectorAll("#editBtn").forEach((btn) => {
btn.addEventListener("click", function (event) {
const noteId = event.target.closest("#editBtn").dataset.id;
const noteContainer = event.target.closest("#note");
const noteContent = noteContainer.querySelector(".textNote");
// Получаем существующие изображения заметки
const existingImages = Array.isArray(noteContainer.dataset.images)
? JSON.parse(noteContainer.dataset.images)
: [];
// Получаем изображения из контейнера заметки
const imagesContainer = noteContainer.querySelector(
".note-images-container"
);
let noteImagesList = [];
if (imagesContainer) {
const imageElements =
imagesContainer.querySelectorAll(".note-image-item");
imageElements.forEach((imgElement) => {
const img = imgElement.querySelector("img");
if (img) {
// Получаем id из data-атрибута изображения или из URL
const imageId =
img.dataset.imageId || img.getAttribute("data-image-id") || null;
noteImagesList.push({
id: imageId,
src: img.dataset.imageSrc || img.src,
name: img.alt,
});
}
});
}
// Получаем файлы из контейнера заметки
const filesContainer = noteContainer.querySelector(
".note-files-container"
);
let noteFilesList = [];
if (filesContainer) {
const fileElements = filesContainer.querySelectorAll(".note-file-item");
fileElements.forEach((fileElement) => {
const link = fileElement.querySelector(".note-file-link");
if (link) {
const fileId = link.dataset.fileId || null;
const fileName =
link.querySelector(".file-name")?.textContent || "";
const fileSize =
link.querySelector(".file-size")?.textContent || "";
const filePath = link.getAttribute("href") || "";
noteFilesList.push({
id: fileId,
name: fileName,
size: fileSize,
path: filePath,
});
}
});
}
// Разворачиваем заметку при редактировании
noteContent.classList.remove("collapsed");
// Скрываем кнопку "Показать полностью" если она есть
const showMoreBtn = noteContainer.querySelector(".show-more-btn");
if (showMoreBtn) {
showMoreBtn.style.display = "none";
}
// Скрываем контейнер с изображениями заметки при редактировании
if (imagesContainer) {
imagesContainer.style.display = "none";
}
// Скрываем контейнер с файлами заметки при редактировании
if (filesContainer) {
filesContainer.style.display = "none";
}
// Создаем контейнер для markdown кнопок
const markdownButtonsContainer = document.createElement("div");
markdownButtonsContainer.classList.add(
"markdown-buttons",
"markdown-buttons--edit"
);
// Создаем markdown кнопки
const markdownButtons = [
{ id: "editBoldBtn", icon: "mdi:format-bold", tag: "**" },
{ id: "editItalicBtn", icon: "mdi:format-italic", tag: "*" },
{
id: "editStrikethroughBtn",
icon: "mdi:format-strikethrough",
tag: "~~",
},
{ id: "editColorBtn", icon: "mdi:palette", tag: "color" },
{ id: "editSpoilerBtn", icon: "mdi:eye-off", tag: "spoiler" },
{ id: "editHeaderBtn", icon: "mdi:format-header-pound", tag: "header" },
{ id: "editListBtn", icon: "mdi:format-list-bulleted", tag: "- " },
{
id: "editNumberedListBtn",
icon: "mdi:format-list-numbered",
tag: "1. ",
},
{ id: "editQuoteBtn", icon: "mdi:format-quote-close", tag: "> " },
{ id: "editCodeBtn", icon: "mdi:code-tags", tag: "`" },
{ id: "editLinkBtn", icon: "mdi:link", tag: "[Текст ссылки](URL)" },
{
id: "editCheckboxBtn",
icon: "mdi:checkbox-marked-outline",
tag: "- [ ] ",
},
{ id: "editImageBtn", icon: "mdi:image-plus", tag: "image" },
{ id: "editFileBtn", icon: "mdi:file-plus", tag: "file" },
{ id: "editPreviewBtn", icon: "mdi:eye", tag: "preview" },
];
markdownButtons.forEach((button) => {
if (button.tag === "header") {
// Создаем контейнер для кнопки заголовка с dropdown
const headerContainer = document.createElement("div");
headerContainer.classList.add("header-dropdown");
headerContainer.style.position = "relative";
headerContainer.style.display = "inline-block";
const headerBtn = document.createElement("button");
headerBtn.classList.add("btnMarkdown");
headerBtn.id = button.id;
headerBtn.innerHTML = `
<span class="iconify" data-icon="${button.icon}"></span>
<span class="iconify" data-icon="mdi:menu-down" style="font-size: 10px; margin-left: -2px"></span>
`;
const headerDropdown = document.createElement("div");
headerDropdown.classList.add("header-dropdown-menu");
headerDropdown.style.display = "none";
// Создаем опции для каждого уровня заголовка
for (let i = 1; i <= 6; i++) {
const headerOption = document.createElement("button");
headerOption.type = "button";
headerOption.textContent = `H${i}`;
headerOption.dataset.level = i;
headerOption.addEventListener("click", function (e) {
e.stopPropagation();
const headerTag = "#".repeat(i) + " ";
insertMarkdownForEdit(textarea, headerTag);
headerDropdown.style.display = "none";
headerBtn.classList.remove("active");
});
headerDropdown.appendChild(headerOption);
}
// Обработчик открытия/закрытия dropdown
headerBtn.addEventListener("click", function (e) {
e.stopPropagation();
headerDropdown.style.display =
headerDropdown.style.display === "none" ? "block" : "none";
headerBtn.classList.toggle("active");
});
// Закрытие dropdown при клике вне его
document.addEventListener("click", function closeHeaderDropdown(e) {
if (!headerContainer.contains(e.target)) {
headerDropdown.style.display = "none";
headerBtn.classList.remove("active");
}
});
headerContainer.appendChild(headerBtn);
headerContainer.appendChild(headerDropdown);
markdownButtonsContainer.appendChild(headerContainer);
} else {
const btn = document.createElement("button");
btn.classList.add("btnMarkdown");
btn.id = button.id;
btn.innerHTML = `<span class="iconify" data-icon="${button.icon}"></span>`;
markdownButtonsContainer.appendChild(btn);
}
});
// Создаем textarea с уже существующим классом textInput
const textarea = document.createElement("textarea");
textarea.classList.add("textInput");
// Получаем исходный markdown контент из data-атрибута или используем textContent как fallback
textarea.value =
noteContent.dataset.originalContent || noteContent.textContent;
// Привязываем авторасширение к textarea для редактирования
textarea.addEventListener("input", function () {
autoExpandTextarea(textarea);
});
// Добавляем обработчик клавиатуры для автоматического продолжения списков
textarea.addEventListener("keydown", function (event) {
if (event.key === "Enter") {
// Автоматическое продолжение списков в режиме редактирования
const start = textarea.selectionStart;
const text = textarea.value;
const lines = text.split("\n");
// Определяем текущую строку
let currentLineIndex = 0;
let currentLineStart = 0;
let currentLine = "";
for (let i = 0; i < lines.length; i++) {
const lineLength = lines[i].length;
if (currentLineStart + lineLength >= start) {
currentLineIndex = i;
currentLine = lines[i];
break;
}
currentLineStart += lineLength + 1; // +1 для символа новой строки
}
// Проверяем, является ли текущая строка списком
const listPatterns = [
/^(\s*)- \[ \] /, // Чекбокс (не отмечен): - [ ]
/^(\s*)- \[x\] /i, // Чекбокс (отмечен): - [x]
/^(\s*)- /, // Неупорядоченный список: -
/^(\s*)\* /, // Неупорядоченный список: *
/^(\s*)\+ /, // Неупорядоченный список: +
/^(\s*)(\d+)\. /, // Упорядоченный список: 1. 2. 3.
/^(\s*)(\w+)\. /, // Буквенный список: a. b. c.
];
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) {
event.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"
);
textarea.value = newBefore + afterCursor;
// Устанавливаем курсор после удаленного маркера
const newCursorPos = newBefore.length;
textarea.setSelectionRange(newCursorPos, newCursorPos);
} 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. ";
}
textarea.value = beforeCursor + "\n" + newMarker + afterCursor;
// Устанавливаем курсор после нового маркера
const newCursorPos = start + 1 + newMarker.length;
textarea.setSelectionRange(newCursorPos, newCursorPos);
}
// Обновляем высоту textarea
autoExpandTextarea(textarea);
}
}
});
// Создаем элементы для загрузки изображений в режиме редактирования
const editImageInput = document.createElement("input");
editImageInput.type = "file";
editImageInput.accept = "image/*";
editImageInput.multiple = true;
editImageInput.style.display = "none";
editImageInput.id = `editImageInput-${noteId}`;
// Создаем элементы для загрузки файлов в режиме редактирования
const editFileInput = document.createElement("input");
editFileInput.type = "file";
editFileInput.accept = ".pdf,.doc,.docx,.xls,.xlsx,.txt,.zip,.rar,.7z";
editFileInput.multiple = true;
editFileInput.style.display = "none";
editFileInput.id = `editFileInput-${noteId}`;
// Контейнер для существующих изображений
const existingImagesContainer = document.createElement("div");
existingImagesContainer.id = `existingImagesContainer-${noteId}`;
existingImagesContainer.classList.add("image-preview-container");
existingImagesContainer.style.display =
noteImagesList.length > 0 ? "block" : "none";
const existingImagesHeader = document.createElement("div");
existingImagesHeader.classList.add("image-preview-header");
existingImagesHeader.innerHTML = `<span>Прикрепленные изображения:</span>`;
const existingImagesList = document.createElement("div");
existingImagesList.id = `existingImagesList-${noteId}`;
existingImagesList.classList.add("image-preview-list");
existingImagesContainer.appendChild(existingImagesHeader);
existingImagesContainer.appendChild(existingImagesList);
// Массив для отслеживания удаленных изображений
const deletedImagesIds = [];
// Отображаем существующие изображения
if (noteImagesList.length > 0) {
noteImagesList.forEach((image) => {
const previewItem = document.createElement("div");
previewItem.className = "image-preview-item";
previewItem.dataset.imageId = image.id;
const img = document.createElement("img");
img.src = image.src;
img.alt = image.name || "Изображение";
img.loading = "lazy";
const removeBtn = document.createElement("button");
removeBtn.className = "remove-image-btn";
removeBtn.textContent = "×";
removeBtn.title = "Удалить изображение";
// Обработчик удаления существующего изображения
removeBtn.addEventListener("click", function () {
deletedImagesIds.push(image.id);
previewItem.remove();
// Если больше нет изображений, скрываем контейнер
if (existingImagesList.children.length === 0) {
existingImagesContainer.style.display = "none";
}
});
const imageInfo = document.createElement("div");
imageInfo.className = "image-info";
imageInfo.textContent = image.name || "Изображение";
previewItem.appendChild(img);
previewItem.appendChild(removeBtn);
previewItem.appendChild(imageInfo);
existingImagesList.appendChild(previewItem);
});
}
const editImagePreviewContainer = document.createElement("div");
editImagePreviewContainer.id = `editImagePreviewContainer-${noteId}`;
editImagePreviewContainer.classList.add("image-preview-container");
editImagePreviewContainer.style.display = "none";
const editImagePreviewHeader = document.createElement("div");
editImagePreviewHeader.classList.add("image-preview-header");
editImagePreviewHeader.innerHTML = `
<span>Новые изображения:</span>
<button type="button" class="clear-images-btn" id="editClearImagesBtn-${noteId}">Очистить все</button>
`;
const editImagePreviewList = document.createElement("div");
editImagePreviewList.id = `editImagePreviewList-${noteId}`;
editImagePreviewList.classList.add("image-preview-list");
editImagePreviewContainer.appendChild(editImagePreviewHeader);
editImagePreviewContainer.appendChild(editImagePreviewList);
// Массив для хранения новых изображений в режиме редактирования
const editSelectedImages = [];
// Контейнер для существующих файлов
const existingFilesContainer = document.createElement("div");
existingFilesContainer.id = `existingFilesContainer-${noteId}`;
existingFilesContainer.classList.add("file-preview-container");
existingFilesContainer.style.display =
noteFilesList.length > 0 ? "block" : "none";
const existingFilesHeader = document.createElement("div");
existingFilesHeader.classList.add("file-preview-header");
existingFilesHeader.innerHTML = `<span>Прикрепленные файлы:</span>`;
const existingFilesList = document.createElement("div");
existingFilesList.id = `existingFilesList-${noteId}`;
existingFilesList.classList.add("file-preview-list");
existingFilesContainer.appendChild(existingFilesHeader);
existingFilesContainer.appendChild(existingFilesList);
// Массив для отслеживания удаленных файлов
const deletedFilesIds = [];
// Отображаем существующие файлы
if (noteFilesList.length > 0) {
noteFilesList.forEach((file) => {
const previewItem = document.createElement("div");
previewItem.className = "file-preview-item";
previewItem.dataset.fileId = file.id;
const ext = file.name.split(".").pop().toLowerCase();
let icon = "mdi:file";
if (ext === "pdf") icon = "mdi:file-pdf-box";
else if (["doc", "docx"].includes(ext)) icon = "mdi:file-word-box";
else if (["xls", "xlsx"].includes(ext)) icon = "mdi:file-excel-box";
else if (ext === "txt") icon = "mdi:file-document-outline";
else if (["zip", "rar", "7z"].includes(ext)) icon = "mdi:folder-zip";
const fileIcon = document.createElement("span");
fileIcon.className = "iconify file-icon";
fileIcon.setAttribute("data-icon", icon);
const fileInfo = document.createElement("div");
fileInfo.className = "file-info-edit";
fileInfo.innerHTML = `
<div class="file-name">${file.name}</div>
<div class="file-size">${file.size}</div>
`;
const removeBtn = document.createElement("button");
removeBtn.className = "remove-file-btn";
removeBtn.textContent = "×";
removeBtn.title = "Удалить файл";
// Обработчик удаления существующего файла
removeBtn.addEventListener("click", function () {
deletedFilesIds.push(file.id);
previewItem.remove();
// Если больше нет файлов, скрываем контейнер
if (existingFilesList.children.length === 0) {
existingFilesContainer.style.display = "none";
}
});
previewItem.appendChild(fileIcon);
previewItem.appendChild(fileInfo);
previewItem.appendChild(removeBtn);
existingFilesList.appendChild(previewItem);
});
}
const editFilePreviewContainer = document.createElement("div");
editFilePreviewContainer.id = `editFilePreviewContainer-${noteId}`;
editFilePreviewContainer.classList.add("file-preview-container");
editFilePreviewContainer.style.display = "none";
const editFilePreviewHeader = document.createElement("div");
editFilePreviewHeader.classList.add("file-preview-header");
editFilePreviewHeader.innerHTML = `
<span>Новые файлы:</span>
<button type="button" class="clear-files-btn" id="editClearFilesBtn-${noteId}">Очистить все</button>
`;
const editFilePreviewList = document.createElement("div");
editFilePreviewList.id = `editFilePreviewList-${noteId}`;
editFilePreviewList.classList.add("file-preview-list");
editFilePreviewContainer.appendChild(editFilePreviewHeader);
editFilePreviewContainer.appendChild(editFilePreviewList);
// Массив для хранения новых файлов в режиме редактирования
const editSelectedFiles = [];
// Контейнер для кнопки сохранения и подсказки
const saveButtonContainer = document.createElement("div");
saveButtonContainer.classList.add("save-button-container");
// Контейнер для кнопок действий
const actionButtons = document.createElement("div");
actionButtons.classList.add("action-buttons");
// Кнопка ИИ помощи
const aiImproveEditBtn = document.createElement("button");
aiImproveEditBtn.classList.add("btnSave", "btnAI");
aiImproveEditBtn.innerHTML =
'<span class="iconify" data-icon="mdi:robot"></span> Помощь ИИ';
aiImproveEditBtn.title = "Улучшить или создать текст через ИИ";
// Проверяем настройку AI и скрываем кнопку если отключено
const aiEnabled = localStorage.getItem("ai_enabled");
if (aiEnabled !== "1") {
aiImproveEditBtn.style.display = "none";
}
// Кнопка сохранить
const saveEditBtn = document.createElement("button");
saveEditBtn.textContent = "Сохранить";
saveEditBtn.classList.add("btnSave");
// Кнопка отмены
const cancelEditBtn = document.createElement("button");
cancelEditBtn.textContent = "Отмена";
cancelEditBtn.classList.add("btnSave");
// Подсказка о горячей клавише
const saveHint = document.createElement("span");
saveHint.classList.add("save-hint");
saveHint.textContent = "или нажмите Alt + Enter";
// Добавляем кнопки в контейнер действий
actionButtons.appendChild(aiImproveEditBtn);
actionButtons.appendChild(saveEditBtn);
actionButtons.appendChild(cancelEditBtn);
saveButtonContainer.appendChild(actionButtons);
saveButtonContainer.appendChild(saveHint);
// Функция обновления превью изображений для режима редактирования
const updateEditImagePreview = function () {
if (editSelectedImages.length === 0) {
editImagePreviewContainer.style.display = "none";
return;
}
editImagePreviewContainer.style.display = "block";
editImagePreviewList.innerHTML = "";
editSelectedImages.forEach((file, index) => {
const reader = new FileReader();
reader.onload = function (e) {
const previewItem = document.createElement("div");
previewItem.className = "image-preview-item";
previewItem.innerHTML = `
<img src="${e.target.result}" alt="Preview" loading="lazy">
<button class="remove-image-btn" data-index="${index}">×</button>
<div class="image-info">${file.name}</div>
`;
editImagePreviewList.appendChild(previewItem);
// Обработчик удаления изображения
const removeBtn = previewItem.querySelector(".remove-image-btn");
removeBtn.addEventListener("click", function () {
editSelectedImages.splice(index, 1);
updateEditImagePreview();
});
};
reader.readAsDataURL(file);
});
};
// Функция обновления превью файлов для режима редактирования
const updateEditFilePreview = function () {
if (editSelectedFiles.length === 0) {
editFilePreviewContainer.style.display = "none";
return;
}
editFilePreviewContainer.style.display = "block";
editFilePreviewList.innerHTML = "";
editSelectedFiles.forEach((file, index) => {
const previewItem = document.createElement("div");
previewItem.className = "file-preview-item";
const ext = file.name.split(".").pop().toLowerCase();
let icon = "mdi:file";
if (ext === "pdf") icon = "mdi:file-pdf-box";
else if (["doc", "docx"].includes(ext)) icon = "mdi:file-word-box";
else if (["xls", "xlsx"].includes(ext)) icon = "mdi:file-excel-box";
else if (ext === "txt") icon = "mdi:file-document-outline";
else if (["zip", "rar", "7z"].includes(ext)) icon = "mdi:folder-zip";
const fileSize = (file.size / 1024 / 1024).toFixed(2);
previewItem.innerHTML = `
<span class="iconify file-icon" data-icon="${icon}"></span>
<div class="file-info-edit">
<div class="file-name">${file.name}</div>
<div class="file-size">${fileSize} MB</div>
</div>
<button class="remove-file-btn" data-index="${index}">×</button>
`;
editFilePreviewList.appendChild(previewItem);
// Обработчик удаления файла
const removeBtn = previewItem.querySelector(".remove-file-btn");
removeBtn.addEventListener("click", function () {
editSelectedFiles.splice(index, 1);
updateEditFilePreview();
});
});
};
// Функция загрузки изображений для режима редактирования
const uploadEditImages = async function (noteId) {
if (editSelectedImages.length === 0) {
return [];
}
const formData = new FormData();
editSelectedImages.forEach((file) => {
formData.append("images", file);
});
try {
const response = await fetch(`/api/notes/${noteId}/images`, {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error("Ошибка загрузки изображений");
}
const result = await response.json();
return result.images || [];
} catch (error) {
console.error("Ошибка загрузки изображений:", error);
return [];
}
};
// Функция загрузки файлов для режима редактирования
const uploadEditFiles = async function (noteId) {
if (editSelectedFiles.length === 0) {
return [];
}
const formData = new FormData();
editSelectedFiles.forEach((file) => {
formData.append("files", file);
});
try {
const response = await fetch(`/api/notes/${noteId}/files`, {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error("Ошибка загрузки файлов");
}
const result = await response.json();
return result.files || [];
} catch (error) {
console.error("Ошибка загрузки файлов:", error);
return [];
}
};
// Функция сохранения для редактирования
const saveEditNote = async function () {
if (
textarea.value.trim() !== "" ||
editSelectedImages.length > 0 ||
deletedImagesIds.length > 0 ||
editSelectedFiles.length > 0 ||
deletedFilesIds.length > 0
) {
try {
// Сбрасываем режим предпросмотра перед сохранением
if (isEditPreviewMode) {
isEditPreviewMode = false;
textarea.style.display = "block";
editPreviewContainer.style.display = "none";
}
const response = await fetch(`/api/notes/${noteId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
content: textarea.value || " ", // Минимальный контент, если только изображения/файлы
}),
});
if (!response.ok) {
throw new Error("Ошибка сохранения заметки");
}
// Удаляем изображения, которые были помечены на удаление
for (const imageId of deletedImagesIds) {
await deleteNoteImage(noteId, imageId);
}
// Удаляем файлы, которые были помечены на удаление
for (const fileId of deletedFilesIds) {
await deleteNoteFile(noteId, fileId);
}
// Загружаем новые изображения, если они есть
if (editSelectedImages.length > 0) {
await uploadEditImages(noteId);
}
// Загружаем новые файлы, если они есть
if (editSelectedFiles.length > 0) {
await uploadEditFiles(noteId);
}
// Перезагружаем заметки
await loadNotes(true);
} catch (error) {
console.error("Ошибка:", error);
showNotification("Ошибка сохранения заметки", "error");
}
} else {
showNotification("Введите текст заметки", "warning");
textarea.focus();
}
};
// Функция отмены редактирования
const cancelEditNote = async function () {
const originalMarkdown = noteContent.dataset.originalContent || "";
const hasTextChanges = textarea.value !== originalMarkdown;
const hasNewImages = editSelectedImages.length > 0;
const hasNewFiles = editSelectedFiles.length > 0;
if (hasTextChanges || hasNewImages || hasNewFiles) {
const ok = await showConfirmModal(
"Подтверждение отмены",
"Отменить изменения?",
{ confirmText: "Отменить" }
);
if (!ok) return;
}
// Сбрасываем режим предпросмотра
if (isEditPreviewMode) {
isEditPreviewMode = false;
textarea.style.display = "block";
editPreviewContainer.style.display = "none";
}
// Очистить выбранные новые изображения и превью
editSelectedImages.length = 0;
if (editImagePreviewContainer) {
editImagePreviewContainer.style.display = "none";
}
if (editImagePreviewList) {
editImagePreviewList.innerHTML = "";
}
if (editImageInput) {
editImageInput.value = "";
}
// Очистить выбранные новые файлы и превью
editSelectedFiles.length = 0;
if (editFilePreviewContainer) {
editFilePreviewContainer.style.display = "none";
}
if (editFilePreviewList) {
editFilePreviewList.innerHTML = "";
}
if (editFileInput) {
editFileInput.value = "";
}
// Перерисовать заметки, вернув исходное состояние
await loadNotes(true);
};
// Обработчик горячей клавиши Alt+Enter для сохранения редактирования
textarea.addEventListener("keydown", function (event) {
if (event.altKey && event.key === "Enter") {
event.preventDefault();
saveEditNote();
} else if (event.key === "Escape") {
event.stopPropagation();
event.preventDefault();
cancelEditNote();
}
});
// Обработчики для загрузки изображений в режиме редактирования
editImageInput.addEventListener("change", function (event) {
const files = Array.from(event.target.files);
files.forEach((file) => {
if (file.type.startsWith("image/")) {
editSelectedImages.push(file);
}
});
updateEditImagePreview();
});
// Обработчик очистки всех изображений в режиме редактирования
const editClearImagesBtn = editImagePreviewHeader.querySelector(
`#editClearImagesBtn-${noteId}`
);
editClearImagesBtn.addEventListener("click", function () {
editSelectedImages.length = 0;
updateEditImagePreview();
editImageInput.value = "";
});
// Обработчики для загрузки файлов в режиме редактирования
editFileInput.addEventListener("change", function (event) {
const files = Array.from(event.target.files);
const allowedExtensions = /\.(pdf|doc|docx|xls|xlsx|txt|zip|rar|7z)$/i;
files.forEach((file) => {
if (allowedExtensions.test(file.name)) {
if (file.size <= 50 * 1024 * 1024) {
editSelectedFiles.push(file);
} else {
showNotification(
`Файл ${file.name} слишком большой (максимум 50 МБ)`,
"error"
);
}
} else {
showNotification(
`Файл ${file.name} имеет недопустимый формат`,
"error"
);
}
});
updateEditFilePreview();
});
// Обработчик очистки всех файлов в режиме редактирования
const editClearFilesBtn = editFilePreviewHeader.querySelector(
`#editClearFilesBtn-${noteId}`
);
editClearFilesBtn.addEventListener("click", function () {
editSelectedFiles.length = 0;
updateEditFilePreview();
editFileInput.value = "";
});
// Создаем контейнер для предпросмотра в режиме редактирования
const editPreviewContainer = document.createElement("div");
editPreviewContainer.classList.add("note-preview-container");
editPreviewContainer.style.display = "none";
const editPreviewHeader = document.createElement("div");
editPreviewHeader.classList.add("note-preview-header");
editPreviewHeader.innerHTML = "<span>Предпросмотр:</span>";
const editPreviewContent = document.createElement("div");
editPreviewContent.classList.add("note-preview-content");
editPreviewContainer.appendChild(editPreviewHeader);
editPreviewContainer.appendChild(editPreviewContent);
// Флаг для режима предпросмотра редактирования
let isEditPreviewMode = false;
// Очищаем текущий контент и вставляем markdown кнопки, textarea, элементы для изображений и файлов и контейнер с кнопкой сохранить
noteContent.innerHTML = "";
noteContent.appendChild(markdownButtonsContainer);
noteContent.appendChild(textarea);
noteContent.appendChild(editPreviewContainer);
noteContent.appendChild(editImageInput);
noteContent.appendChild(editFileInput);
noteContent.appendChild(existingImagesContainer);
noteContent.appendChild(editImagePreviewContainer);
noteContent.appendChild(existingFilesContainer);
noteContent.appendChild(editFilePreviewContainer);
noteContent.appendChild(saveButtonContainer);
// Применяем авторасширение после добавления в DOM
setTimeout(() => {
autoExpandTextarea(textarea);
textarea.focus();
}, 0);
// Добавляем обработчики для markdown кнопок редактирования
markdownButtons.forEach((button) => {
if (button.tag === "header") {
// Header имеет собственный обработчик в dropdown
return;
}
const btn = document.getElementById(button.id);
btn.addEventListener("click", function () {
if (button.tag === "image") {
// Для кнопки изображения открываем диалог выбора файлов
editImageInput.click();
} else if (button.tag === "file") {
// Для кнопки файлов открываем диалог выбора файлов
editFileInput.click();
} else if (button.tag === "color") {
// Для кнопки цвета открываем диалог выбора цвета
insertColorTagForEdit(textarea);
} else if (button.tag === "spoiler") {
// Вставка спойлера в режиме редактирования
insertSpoilerForEdit(textarea);
} else if (button.tag === "preview") {
// Для кнопки предпросмотра переключаем режим
isEditPreviewMode = !isEditPreviewMode;
if (isEditPreviewMode) {
// Показываем предпросмотр
textarea.style.display = "none";
editPreviewContainer.style.display = "block";
// Получаем содержимое и рендерим его
const content = textarea.value;
if (content.trim()) {
// Парсим markdown и делаем теги кликабельными
const htmlContent = marked.parse(content);
const contentWithTags = makeTagsClickable(htmlContent);
editPreviewContent.innerHTML = contentWithTags;
// Применяем текущую тему к предпросмотру редактирования
applyThemeToEditPreview(
editPreviewContainer,
editPreviewContent
);
// Добавляем обработчики спойлеров внутри предпросмотра редактирования
addSpoilerEventListeners();
// Инициализируем lazy loading для изображений в превью
setTimeout(() => {
initLazyLoading();
}, 0);
} else {
editPreviewContent.innerHTML =
'<p style="color: var(--text-muted); font-style: italic;">Нет содержимого для предпросмотра</p>';
}
// Меняем иконку кнопки
btn.innerHTML =
'<span class="iconify" data-icon="mdi:eye-off"></span>';
btn.title = "Закрыть предпросмотр";
} else {
// Возвращаемся к редактированию
textarea.style.display = "block";
editPreviewContainer.style.display = "none";
// Меняем иконку обратно
btn.innerHTML =
'<span class="iconify" data-icon="mdi:eye"></span>';
btn.title = "Предпросмотр";
}
} else {
insertMarkdownForEdit(textarea, button.tag);
}
});
});
// Обработчик кнопки ИИ для редактирования
aiImproveEditBtn.addEventListener("click", async function () {
const textarea = noteContainer.querySelector(".textInput");
const content = textarea.value.trim();
if (!content) {
showNotification("Введите текст для улучшения", "warning");
return;
}
// Показываем индикатор загрузки
const originalHTML = aiImproveEditBtn.innerHTML;
const originalTitle = aiImproveEditBtn.title;
aiImproveEditBtn.disabled = true;
aiImproveEditBtn.innerHTML =
'<span class="iconify" data-icon="mdi:loading" style="animation: spin 1s linear infinite;"></span> Обработка...';
aiImproveEditBtn.title = "Обработка...";
try {
const response = await fetch("/api/ai/improve", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ text: content }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Ошибка улучшения текста");
}
// Заменяем текст на улучшенный
textarea.value = data.improvedText;
// Авторасширяем textarea
autoExpandTextarea(textarea);
showNotification("Текст успешно улучшен!", "success");
} catch (error) {
console.error("Ошибка улучшения текста:", error);
showNotification(error.message || "Ошибка улучшения текста", "error");
} finally {
// Восстанавливаем кнопку
aiImproveEditBtn.disabled = false;
aiImproveEditBtn.innerHTML = originalHTML;
aiImproveEditBtn.title = originalTitle;
}
});
// Обработчик сохранения редактирования
saveEditBtn.addEventListener("click", saveEditNote);
// Обработчик отмены редактирования
cancelEditBtn.addEventListener("click", cancelEditNote);
});
});
}
// Функция для добавления обработчиков кликов на теги в заметках
function addTagClickListeners() {
document.querySelectorAll(".textNote .tag-in-note").forEach((tagElement) => {
tagElement.addEventListener("click", async function (event) {
event.preventDefault();
event.stopPropagation();
const clickedTag = this.dataset.tag.toLowerCase();
// Если кликнули на тот же тег, снимаем фильтр
if (selectedTagFilter === clickedTag) {
selectedTagFilter = null;
} else {
selectedTagFilter = clickedTag;
}
// Перерисовываем заметки и теги
await renderNotes(allNotes);
renderTags();
renderTagsMobile();
updateFilterIndicator();
});
});
}
// Функция для добавления обработчиков для изображений в заметках
function addImageEventListeners() {
// Обработчики для кликов на изображения (открытие в модальном окне)
document.querySelectorAll(".note-image").forEach((imageElement) => {
// Проверяем, не добавлен ли уже обработчик
if (imageElement._clickHandler) {
return; // Пропускаем, если обработчик уже добавлен
}
// Создаем новый обработчик
imageElement._clickHandler = function (event) {
event.preventDefault();
event.stopPropagation();
const imageSrc = this.dataset.imageSrc;
console.log("Image clicked, src:", imageSrc); // Для отладки
if (imageSrc) {
showImageModal(imageSrc);
}
};
imageElement.addEventListener("click", imageElement._clickHandler);
});
// Обработчики для кнопок удаления изображений удалены - кнопки удаления только в режиме редактирования
}
// Функция для добавления обработчиков внешних ссылок
function addExternalLinkListeners() {
// Обработчики для внешних ссылок
document.querySelectorAll(".external-link").forEach((linkElement) => {
// Проверяем, не добавлен ли уже обработчик
if (linkElement._externalClickHandler) {
return; // Пропускаем, если обработчик уже добавлен
}
// Создаем новый обработчик
linkElement._externalClickHandler = function (event) {
// Для PWA приложений открываем ссылку в браузере
if (
window.matchMedia("(display-mode: standalone)").matches ||
window.navigator.standalone === true
) {
event.preventDefault();
window.open(this.href, "_blank", "noopener,noreferrer");
}
// В обычном браузере оставляем стандартное поведение (target="_blank")
};
linkElement.addEventListener("click", linkElement._externalClickHandler);
});
}
// Функция для применения визуальных стилей к чекбоксу
function applyCheckboxStyles(checkbox, checked) {
const parentLi = checkbox.closest("li");
if (!parentLi) return;
if (checked) {
parentLi.style.opacity = "0.65";
// Находим все элементы кроме чекбокса
const textNodes = parentLi.querySelectorAll("*:not(input)");
textNodes.forEach((node) => {
node.style.textDecoration = "line-through";
node.style.color = "#999";
});
// Обрабатываем текстовые узлы
Array.from(parentLi.childNodes).forEach((node) => {
if (node.nodeType === 3 && node.textContent.trim()) {
const span = document.createElement("span");
span.style.textDecoration = "line-through";
span.style.color = "#999";
span.textContent = node.textContent;
parentLi.replaceChild(span, node);
}
});
} else {
parentLi.style.opacity = "1";
const textNodes = parentLi.querySelectorAll("*:not(input)");
textNodes.forEach((node) => {
node.style.textDecoration = "none";
node.style.color = "";
});
}
}
// Функция для добавления обработчиков для чекбоксов в заметках
function addCheckboxEventListeners() {
// Находим все чекбоксы в заметках
document
.querySelectorAll(".textNote input[type='checkbox']")
.forEach((checkbox) => {
// Применяем начальные стили для уже отмеченных чекбоксов
if (checkbox.checked) {
applyCheckboxStyles(checkbox, true);
}
// Удаляем старые обработчики, если они есть
if (checkbox._checkboxHandler) {
checkbox.removeEventListener("change", checkbox._checkboxHandler);
}
// Создаем новый обработчик
checkbox._checkboxHandler = async function (event) {
// Применяем визуальные эффекты сразу
applyCheckboxStyles(this, this.checked);
// Находим родительскую заметку
const noteContainer = this.closest("#note");
if (!noteContainer) return;
const noteId = noteContainer.dataset.noteId;
const textNoteElement = noteContainer.querySelector(".textNote");
// Получаем исходный markdown контент
let originalContent = textNoteElement.dataset.originalContent;
if (!originalContent) return;
// Находим индекс чекбокса в списке всех чекбоксов этой заметки
const allCheckboxes = textNoteElement.querySelectorAll(
"input[type='checkbox']"
);
const checkboxIndex = Array.from(allCheckboxes).indexOf(this);
// Находим все чекбоксы в markdown контенте
const checkboxRegex = /- \[([ x])\]/gi;
let matches = [];
let match;
while ((match = checkboxRegex.exec(originalContent)) !== null) {
matches.push({
index: match.index,
checked: match[1].toLowerCase() === "x",
fullMatch: match[0],
});
}
// Если нашли соответствующий чекбокс, изменяем его состояние
if (matches[checkboxIndex]) {
const targetMatch = matches[checkboxIndex];
const newState = this.checked ? "- [x]" : "- [ ]";
// Заменяем состояние чекбокса в исходном тексте
const beforeCheckbox = originalContent.substring(
0,
targetMatch.index
);
const afterCheckbox = originalContent.substring(
targetMatch.index + targetMatch.fullMatch.length
);
originalContent = beforeCheckbox + newState + afterCheckbox;
// Обновляем data-атрибут
textNoteElement.dataset.originalContent = originalContent;
// Автоматически сохраняем изменения на сервере
try {
const response = await fetch(`/api/notes/${noteId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
content: originalContent,
}),
});
if (!response.ok) {
throw new Error("Ошибка сохранения заметки");
}
// Обновляем кэш заметок
const noteInCache = allNotes.find((n) => n.id == noteId);
if (noteInCache) {
noteInCache.content = originalContent;
}
} catch (error) {
console.error("Ошибка автосохранения чекбокса:", error);
// Если не удалось сохранить, возвращаем чекбокс в прежнее состояние
this.checked = !this.checked;
showNotification("Ошибка сохранения изменений", "error");
}
}
};
checkbox.addEventListener("change", checkbox._checkboxHandler);
});
}
function addSpoilerEventListeners() {
document.querySelectorAll(".spoiler").forEach((spoiler) => {
// Проверяем, не добавлен ли уже обработчик
if (spoiler._clickHandler) {
return; // Пропускаем, если обработчик уже добавлен
}
// Создаем новый обработчик
spoiler._clickHandler = function (event) {
// Если уже раскрыт — не мешаем выделению текста
if (this.classList.contains("revealed")) {
return;
}
event.stopPropagation();
this.classList.add("revealed");
console.log("Спойлер кликнут:", this.textContent);
};
spoiler.addEventListener("click", spoiler._clickHandler);
});
}
// Функция сохранения заметки (вынесена отдельно для повторного использования)
async function saveNote() {
if (
noteInput.value.trim() !== "" ||
selectedImages.length > 0 ||
selectedFiles.length > 0
) {
try {
const { date, time } = getFormattedDateTime();
// Показываем индикатор сохранения для мобильных устройств
const isMobile =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
) ||
(navigator.maxTouchPoints && navigator.maxTouchPoints > 2) ||
window.matchMedia("(max-width: 768px)").matches;
let savingIndicator = null;
if (isMobile) {
savingIndicator = document.createElement("div");
savingIndicator.id = "mobile-saving-indicator";
savingIndicator.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 20px;
border-radius: 10px;
z-index: 10000;
font-size: 16px;
text-align: center;
`;
const attachmentsInfo = [];
if (selectedImages.length > 0) {
attachmentsInfo.push(`${selectedImages.length} изображений`);
}
if (selectedFiles.length > 0) {
attachmentsInfo.push(`${selectedFiles.length} файлов`);
}
savingIndicator.innerHTML = `
<div>💾 Сохранение заметки...</div>
${
attachmentsInfo.length > 0
? `<div style="font-size: 12px; margin-top: 10px;">+ ${attachmentsInfo.join(
", "
)}</div>`
: ""
}
`;
document.body.appendChild(savingIndicator);
}
const response = await fetch("/api/notes", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
content: noteInput.value || " ", // Минимальный контент, если только изображения
date: date,
time: time,
}),
});
if (!response.ok) {
throw new Error("Ошибка сохранения заметки");
}
const noteData = await response.json();
const noteId = noteData.id;
// Загружаем изображения, если они есть
if (selectedImages.length > 0) {
await uploadImages(noteId);
}
// Загружаем файлы, если они есть
if (selectedFiles.length > 0) {
await uploadFiles(noteId);
}
// Удаляем индикатор сохранения
if (savingIndicator) {
savingIndicator.remove();
}
// Очищаем поле ввода, изображения и файлы, перезагружаем заметки
noteInput.value = "";
noteInput.style.height = "auto";
selectedImages = [];
selectedFiles = [];
updateImagePreview();
updateFilePreview();
imageInput.value = "";
fileInput.value = "";
// Сбрасываем режим предпросмотра, если он был активен
if (isPreviewMode) {
isPreviewMode = false;
noteInput.style.display = "block";
notePreviewContainer.style.display = "none";
previewBtn.innerHTML =
'<span class="iconify" data-icon="mdi:eye"></span>';
previewBtn.title = "Предпросмотр";
}
await loadNotes(true);
// Показываем уведомление об успешном сохранении
if (isMobile) {
const successDiv = document.createElement("div");
successDiv.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: #28a745;
color: white;
padding: 10px 20px;
border-radius: 5px;
z-index: 10000;
font-size: 14px;
`;
successDiv.textContent = "✅ Заметка сохранена!";
document.body.appendChild(successDiv);
setTimeout(() => {
successDiv.remove();
}, 3000);
}
} catch (error) {
console.error("Ошибка:", error);
// Удаляем индикатор сохранения в случае ошибки
const savingIndicator = document.getElementById(
"mobile-saving-indicator"
);
if (savingIndicator) {
savingIndicator.remove();
}
showNotification("Ошибка сохранения заметки", "error");
}
} else {
showNotification("Введите текст заметки", "warning");
noteInput.focus();
}
}
// Обработчик сохранения новой заметки
saveBtn.addEventListener("click", saveNote);
// Обработчик горячей клавиши Alt+Enter для сохранения заметки
noteInput.addEventListener("keydown", function (event) {
if (event.altKey && event.key === "Enter") {
event.preventDefault(); // Предотвращаем стандартное поведение
saveNote();
} else if (event.key === "Enter") {
// Автоматическое продолжение списков
const textarea = event.target;
const start = textarea.selectionStart;
const text = textarea.value;
const lines = text.split("\n");
// Определяем текущую строку
let currentLineIndex = 0;
let currentLineStart = 0;
let currentLine = "";
for (let i = 0; i < lines.length; i++) {
const lineLength = lines[i].length;
if (currentLineStart + lineLength >= start) {
currentLineIndex = i;
currentLine = lines[i];
break;
}
currentLineStart += lineLength + 1; // +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) {
event.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"
);
textarea.value = newBefore + afterCursor;
// Устанавливаем курсор после удаленного маркера
const newCursorPos = newBefore.length;
textarea.setSelectionRange(newCursorPos, newCursorPos);
} 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. ";
}
textarea.value = beforeCursor + "\n" + newMarker + afterCursor;
// Устанавливаем курсор после нового маркера
const newCursorPos = start + 1 + newMarker.length;
textarea.setSelectionRange(newCursorPos, newCursorPos);
}
// Обновляем высоту textarea
autoExpandTextarea(textarea);
}
}
});
// Загружаем заметки при загрузке страницы
document.addEventListener("DOMContentLoaded", function () {
// Проверяем аутентификацию при загрузке страницы
checkAuthentication();
loadUserInfo();
loadNotes();
updateFilterIndicator();
// Инициализируем lazy loading для изображений
initLazyLoading();
// Добавляем обработчик для кнопки выхода
setupLogoutHandler();
// Обработчик для кнопки настроек
if (settingsBtn) {
settingsBtn.addEventListener("click", function () {
window.location.href = "/settings";
});
}
});
// Функция для настройки обработчика выхода
function setupLogoutHandler() {
const logoutForms = document.querySelectorAll('form[action="/logout"]');
logoutForms.forEach((form) => {
form.addEventListener("submit", function (e) {
// Очищаем localStorage перед выходом
localStorage.removeItem("isAuthenticated");
localStorage.removeItem("username");
});
});
}
// Функция для проверки аутентификации
async function checkAuthentication() {
const isAuthenticated = localStorage.getItem("isAuthenticated");
if (isAuthenticated !== "true") {
// Если пользователь не аутентифицирован, перенаправляем на страницу входа
window.location.href = "/";
return;
}
// Проверяем, что сессия на сервере еще действительна
try {
const response = await fetch("/api/auth/status");
if (!response.ok) {
// Если сессия недействительна, очищаем localStorage и перенаправляем
localStorage.removeItem("isAuthenticated");
localStorage.removeItem("username");
window.location.href = "/";
return;
}
const authData = await response.json();
if (!authData.authenticated) {
// Если сервер говорит, что пользователь не аутентифицирован
localStorage.removeItem("isAuthenticated");
localStorage.removeItem("username");
window.location.href = "/";
return;
}
} catch (error) {
console.error("Ошибка проверки аутентификации:", error);
// В случае ошибки сети, оставляем пользователя на странице
// но показываем предупреждение
console.warn("Не удалось проверить статус аутентификации");
}
}
// Функция для загрузки информации о пользователе
async function loadUserInfo() {
try {
const response = await fetch("/api/user");
if (response.ok) {
const user = await response.json();
const userAvatar = document.getElementById("user-avatar");
const userAvatarContainer = document.getElementById(
"user-avatar-container"
);
const userAvatarPlaceholder = document.getElementById(
"user-avatar-placeholder"
);
// Показываем аватарку или плейсхолдер
if (user.avatar) {
if (userAvatar && userAvatarContainer) {
// Проверяем, есть ли аватарка в кэше
const cachedAvatar = getCachedAvatar();
if (cachedAvatar && cachedAvatar.url === user.avatar) {
// Используем кэшированную аватарку
userAvatar.src = cachedAvatar.base64;
console.log("Аватарка загружена из кэша (notes)");
} else {
// Загружаем аватарку с сервера и кэшируем
userAvatar.src = user.avatar;
// Кэшируем в фоне
cacheAvatar(user.avatar).then((success) => {
if (success) {
console.log("Аватарка закэширована (notes)");
}
});
}
userAvatarContainer.style.display = "block";
if (userAvatarPlaceholder) {
userAvatarPlaceholder.style.display = "none";
}
}
} else {
if (userAvatarPlaceholder) {
userAvatarPlaceholder.style.display = "flex";
}
if (userAvatarContainer) {
userAvatarContainer.style.display = "none";
}
// Очищаем кэш, если аватарки нет
clearAvatarCache();
}
// Делаем аватарку и плейсхолдер кликабельными для перехода в профиль
if (userAvatarContainer) {
userAvatarContainer.style.cursor = "pointer";
userAvatarContainer.addEventListener("click", function () {
window.location.href = "/profile";
});
}
if (userAvatarPlaceholder) {
userAvatarPlaceholder.addEventListener("click", function () {
window.location.href = "/profile";
});
}
// Применяем цветовой акцент пользователя (только если отличается от текущего)
const currentColor = getComputedStyle(document.documentElement)
.getPropertyValue("--accent-color")
.trim();
const newColor = user.accent_color || "#007bff";
if (currentColor !== newColor) {
document.documentElement.style.setProperty("--accent-color", newColor);
}
}
} catch (error) {
console.error("Ошибка загрузки информации о пользователе:", error);
}
}
// Календарь
let currentDate = new Date();
// Функция для преобразования timestamp в формат dd.mm.yyyy
function formatDateFromTimestamp(timestamp) {
return formatLocalDateOnly(parseSQLiteUtc(timestamp));
}
// Функция для отображения календаря
function renderCalendar() {
const calendarDays = document.getElementById("calendarDays");
const monthYear = document.getElementById("monthYear");
// Проверяем, существуют ли элементы календаря
if (!calendarDays || !monthYear) return;
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
// Массив названий месяцев
const monthNames = [
"Январь",
"Февраль",
"Март",
"Апрель",
"Май",
"Июнь",
"Июль",
"Август",
"Сентябрь",
"Октябрь",
"Ноябрь",
"Декабрь",
];
// Устанавливаем заголовок месяца и года
monthYear.textContent = `${monthNames[month]} ${year}`;
// Получаем первый день месяца
const firstDay = new Date(year, month, 1);
// Получаем последний день месяца
const lastDay = new Date(year, month + 1, 0);
// Получаем день недели первого дня (0 - воскресенье, 1 - воскресенье, 1 - понедельник и т.д.)
let firstDayOfWeek = firstDay.getDay();
// Преобразуем так, чтобы понедельник был первым днем (0)
firstDayOfWeek = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1;
// Очищаем календарь
calendarDays.innerHTML = "";
// Создаём Set дат, когда были созданы заметки (зеленые кружки)
const createdNoteDates = new Set();
// Создаём Set дат, когда были отредактированы заметки (желтые кружки)
const editedNoteDates = new Set();
allNotes.forEach((note) => {
if (note.created_at) {
createdNoteDates.add(formatDateFromTimestamp(note.created_at));
}
if (note.updated_at && note.created_at !== note.updated_at) {
editedNoteDates.add(formatDateFromTimestamp(note.updated_at));
}
});
// Получаем последний день предыдущего месяца
const prevMonthLastDay = new Date(year, month, 0).getDate();
const prevMonth = month === 0 ? 11 : month - 1;
const prevYear = month === 0 ? year - 1 : year;
// Добавляем дни предыдущего месяца
for (let i = firstDayOfWeek - 1; i >= 0; i--) {
const day = prevMonthLastDay - i;
const dateStr = `${String(day).padStart(2, "0")}.${String(
prevMonth + 1
).padStart(2, "0")}.${prevYear}`;
const dayDiv = document.createElement("div");
dayDiv.classList.add("calendar-day", "other-month");
dayDiv.textContent = day;
dayDiv.dataset.date = dateStr;
// Проверяем, есть ли заметки на этот день
if (createdNoteDates.has(dateStr)) {
dayDiv.classList.add("has-notes");
}
if (editedNoteDates.has(dateStr)) {
dayDiv.classList.add("has-edited-notes");
}
// Проверяем, выбран ли этот день
if (selectedDateFilter === dateStr) {
dayDiv.classList.add("selected");
}
// Добавляем обработчик клика
dayDiv.addEventListener(
"click",
async (event) => await handleDayClick(event)
);
calendarDays.appendChild(dayDiv);
}
// Добавляем дни текущего месяца
const today = new Date();
for (let day = 1; day <= lastDay.getDate(); day++) {
const dateStr = `${String(day).padStart(2, "0")}.${String(
month + 1
).padStart(2, "0")}.${year}`;
const dayDiv = document.createElement("div");
dayDiv.classList.add("calendar-day");
dayDiv.textContent = day;
dayDiv.dataset.date = dateStr;
// Проверяем, является ли день сегодняшним
if (
day === today.getDate() &&
month === today.getMonth() &&
year === today.getFullYear()
) {
dayDiv.classList.add("today");
}
// Проверяем, есть ли заметки на этот день
if (createdNoteDates.has(dateStr)) {
dayDiv.classList.add("has-notes");
}
if (editedNoteDates.has(dateStr)) {
dayDiv.classList.add("has-edited-notes");
}
// Проверяем, выбран ли этот день
if (selectedDateFilter === dateStr) {
dayDiv.classList.add("selected");
}
// Добавляем обработчик клика
dayDiv.addEventListener(
"click",
async (event) => await handleDayClick(event)
);
calendarDays.appendChild(dayDiv);
}
// Добавляем дни следующего месяца
const totalCells = calendarDays.children.length;
const remainingCells = 42 - totalCells; // 6 недель по 7 дней
const nextMonth = month === 11 ? 0 : month + 1;
const nextYear = month === 11 ? year + 1 : year;
for (let day = 1; day <= remainingCells; day++) {
const dateStr = `${String(day).padStart(2, "0")}.${String(
nextMonth + 1
).padStart(2, "0")}.${nextYear}`;
const dayDiv = document.createElement("div");
dayDiv.classList.add("calendar-day", "other-month");
dayDiv.textContent = day;
dayDiv.dataset.date = dateStr;
// Проверяем, есть ли заметки на этот день
if (createdNoteDates.has(dateStr)) {
dayDiv.classList.add("has-notes");
}
if (editedNoteDates.has(dateStr)) {
dayDiv.classList.add("has-edited-notes");
}
// Проверяем, выбран ли этот день
if (selectedDateFilter === dateStr) {
dayDiv.classList.add("selected");
}
// Добавляем обработчик клика
dayDiv.addEventListener(
"click",
async (event) => await handleDayClick(event)
);
calendarDays.appendChild(dayDiv);
}
}
// Обработчик клика на день в календаре
async function handleDayClick(event) {
const clickedDate = event.target.dataset.date;
// Если кликнули на тот же день, снимаем фильтр
if (selectedDateFilter === clickedDate) {
selectedDateFilter = null;
} else {
selectedDateFilter = clickedDate;
}
// Перерисовываем заметки, календарь и теги
await renderNotes(allNotes);
renderCalendar();
renderTags();
updateFilterIndicator();
}
// Функция для обновления индикатора фильтра
function updateFilterIndicator() {
const filterIndicator = document.getElementById("filter-indicator");
if (!filterIndicator) return;
const filters = [];
if (searchQuery) {
filters.push(`Поиск: "${searchQuery}"`);
}
if (selectedDateFilter) {
filters.push(`Дата: ${selectedDateFilter}`);
}
if (selectedTagFilter) {
filters.push(`Тег: #${selectedTagFilter}`);
}
if (filters.length > 0) {
filterIndicator.style.display = "inline-block";
filterIndicator.innerHTML = `Фильтр: ${filters.join(
", "
)} <button id="clear-filter-btn">✕</button>`;
// Добавляем обработчик клика для кнопки сброса
const clearBtn = document.getElementById("clear-filter-btn");
if (clearBtn) {
clearBtn.addEventListener("click", clearFilter);
}
} else {
filterIndicator.style.display = "none";
}
}
// Функция для сброса фильтра (глобальная)
window.clearFilter = async function () {
selectedDateFilter = null;
selectedTagFilter = null;
searchQuery = "";
searchResults = [];
// Очищаем поле поиска
const searchInput = document.getElementById("searchInput");
if (searchInput) {
searchInput.value = "";
}
// Скрываем кнопку очистки поиска
const clearSearchBtn = document.getElementById("clearSearchBtn");
if (clearSearchBtn) {
clearSearchBtn.style.display = "none";
}
await renderNotes(allNotes);
renderCalendar();
renderCalendarMobile();
renderTags();
renderTagsMobile();
updateFilterIndicator();
};
// Глобальные функции для работы с изображениями (оставляем только showImageModal для совместимости)
// window.showImageModal удалена, так как она создавала рекурсию
// Обработчики для кнопок навигации календаря
const prevMonthBtn = document.getElementById("prevMonth");
const nextMonthBtn = document.getElementById("nextMonth");
if (prevMonthBtn) {
prevMonthBtn.addEventListener("click", function () {
currentDate.setMonth(currentDate.getMonth() - 1);
renderCalendar();
});
}
if (nextMonthBtn) {
nextMonthBtn.addEventListener("click", function () {
currentDate.setMonth(currentDate.getMonth() + 1);
renderCalendar();
});
}
// Инициализируем календарь при загрузке страницы
document.addEventListener("DOMContentLoaded", function () {
renderCalendar();
// Инициализируем поиск
initSearch();
});
// Функция для инициализации поиска
function initSearch() {
const searchInput = document.getElementById("searchInput");
const clearSearchBtn = document.getElementById("clearSearchBtn");
if (!searchInput || !clearSearchBtn) return;
// Обработчик ввода в поле поиска с задержкой
let searchTimeout;
searchInput.addEventListener("input", function () {
clearTimeout(searchTimeout);
const query = this.value;
// Показываем/скрываем кнопку очистки
if (query.trim()) {
clearSearchBtn.style.display = "block";
} else {
clearSearchBtn.style.display = "none";
}
// Задержка перед поиском для оптимизации
searchTimeout = setTimeout(() => {
searchNotes(query);
updateFilterIndicator();
}, 300);
});
// Обработчик клика на кнопку очистки поиска
clearSearchBtn.addEventListener("click", async function () {
searchInput.value = "";
this.style.display = "none";
searchQuery = "";
searchResults = [];
await renderNotes(allNotes);
updateFilterIndicator();
});
// Обработчик клавиши Escape для очистки поиска
searchInput.addEventListener("keydown", async function (event) {
if (event.key === "Escape") {
this.value = "";
clearSearchBtn.style.display = "none";
searchQuery = "";
searchResults = [];
await renderNotes(allNotes);
updateFilterIndicator();
}
});
}
// ==================== МОБИЛЬНЫЙ СЛАЙДЕР ====================
// Функция для отображения календаря в мобильном слайдере
function renderCalendarMobile() {
const calendarDays = document.getElementById("calendarDaysMobile");
const monthYear = document.getElementById("monthYearMobile");
// Проверяем, существуют ли элементы календаря для мобильной версии
if (!calendarDays || !monthYear) return;
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
// Массив названий месяцев
const monthNames = [
"Январь",
"Февраль",
"Март",
"Апрель",
"Май",
"Июнь",
"Июль",
"Август",
"Сентябрь",
"Октябрь",
"Ноябрь",
"Декабрь",
];
// Устанавливаем заголовок месяца и года
monthYear.textContent = `${monthNames[month]} ${year}`;
// Получаем первый день месяца
const firstDay = new Date(year, month, 1);
// Получаем последний день месяца
const lastDay = new Date(year, month + 1, 0);
// Получаем день недели первого дня (0 - воскресенье, 1 - понедельник и т.д.)
let firstDayOfWeek = firstDay.getDay();
// Преобразуем так, чтобы понедельник был первым днем (0)
firstDayOfWeek = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1;
// Очищаем календарь
calendarDays.innerHTML = "";
// Создаём Set дат, когда были созданы заметки (зеленые кружки)
const createdNoteDates = new Set();
allNotes.forEach((note) => {
if (note.created_at) {
createdNoteDates.add(formatDateFromTimestamp(note.created_at));
}
});
// Создаём Set дат, когда были отредактированы заметки (желтые кружки)
const editedNoteDates = new Set();
allNotes.forEach((note) => {
if (note.updated_at && note.created_at !== note.updated_at) {
editedNoteDates.add(formatDateFromTimestamp(note.updated_at));
}
});
// Получаем последний день предыдущего месяца
const prevMonthLastDay = new Date(year, month, 0).getDate();
const prevMonth = month === 0 ? 11 : month - 1;
const prevYear = month === 0 ? year - 1 : year;
// Добавляем дни предыдущего месяца
for (let i = firstDayOfWeek - 1; i >= 0; i--) {
const day = prevMonthLastDay - i;
const dateStr = `${String(day).padStart(2, "0")}.${String(
prevMonth + 1
).padStart(2, "0")}.${prevYear}`;
const dayDiv = document.createElement("div");
dayDiv.classList.add("calendar-day", "other-month");
dayDiv.textContent = day;
dayDiv.dataset.date = dateStr;
// Проверяем, есть ли заметки на этот день
if (createdNoteDates.has(dateStr)) {
dayDiv.classList.add("has-notes");
}
if (editedNoteDates.has(dateStr)) {
dayDiv.classList.add("has-edited-notes");
}
// Проверяем, выбран ли этот день
if (selectedDateFilter === dateStr) {
dayDiv.classList.add("selected");
}
// Добавляем обработчик клика
dayDiv.addEventListener(
"click",
async (event) => await handleDayClickMobile(event)
);
calendarDays.appendChild(dayDiv);
}
// Добавляем дни текущего месяца
const today = new Date();
for (let day = 1; day <= lastDay.getDate(); day++) {
const dateStr = `${String(day).padStart(2, "0")}.${String(
month + 1
).padStart(2, "0")}.${year}`;
const dayDiv = document.createElement("div");
dayDiv.classList.add("calendar-day");
dayDiv.textContent = day;
dayDiv.dataset.date = dateStr;
// Проверяем, является ли день сегодняшним
if (
day === today.getDate() &&
month === today.getMonth() &&
year === today.getFullYear()
) {
dayDiv.classList.add("today");
}
// Проверяем, есть ли заметки на этот день
if (createdNoteDates.has(dateStr)) {
dayDiv.classList.add("has-notes");
}
if (editedNoteDates.has(dateStr)) {
dayDiv.classList.add("has-edited-notes");
}
// Проверяем, выбран ли этот день
if (selectedDateFilter === dateStr) {
dayDiv.classList.add("selected");
}
// Добавляем обработчик клика
dayDiv.addEventListener(
"click",
async (event) => await handleDayClickMobile(event)
);
calendarDays.appendChild(dayDiv);
}
// Добавляем дни следующего месяца
const totalCells = calendarDays.children.length;
const remainingCells = 42 - totalCells; // 6 недель по 7 дней
const nextMonth = month === 11 ? 0 : month + 1;
const nextYear = month === 11 ? year + 1 : year;
for (let day = 1; day <= remainingCells; day++) {
const dateStr = `${String(day).padStart(2, "0")}.${String(
nextMonth + 1
).padStart(2, "0")}.${nextYear}`;
const dayDiv = document.createElement("div");
dayDiv.classList.add("calendar-day", "other-month");
dayDiv.textContent = day;
dayDiv.dataset.date = dateStr;
// Проверяем, есть ли заметки на этот день
if (createdNoteDates.has(dateStr)) {
dayDiv.classList.add("has-notes");
}
if (editedNoteDates.has(dateStr)) {
dayDiv.classList.add("has-edited-notes");
}
// Проверяем, выбран ли этот день
if (selectedDateFilter === dateStr) {
dayDiv.classList.add("selected");
}
// Добавляем обработчик клика
dayDiv.addEventListener(
"click",
async (event) => await handleDayClickMobile(event)
);
calendarDays.appendChild(dayDiv);
}
}
// Обработчик клика на день в календаре для мобильной версии
async function handleDayClickMobile(event) {
const clickedDate = event.target.dataset.date;
// Если кликнули на тот же день, снимаем фильтр
if (selectedDateFilter === clickedDate) {
selectedDateFilter = null;
} else {
selectedDateFilter = clickedDate;
}
// Перерисовываем заметки, оба календаря и теги
await renderNotes(allNotes);
renderCalendar();
renderCalendarMobile();
renderTags();
renderTagsMobile();
updateFilterIndicator();
}
// Функция для отображения тегов в мобильном слайдере
function renderTagsMobile() {
const tagsContainer = document.getElementById("tagsContainerMobile");
if (!tagsContainer) return;
const tagCounts = getAllTags(allNotes);
const sortedTags = Object.keys(tagCounts).sort();
if (sortedTags.length === 0) {
tagsContainer.innerHTML =
'<div style="font-size: 10px; color: #999; text-align: center;">Нет тегов</div>';
return;
}
tagsContainer.innerHTML = sortedTags
.map((tag) => {
const count = tagCounts[tag];
const isActive = selectedTagFilter === tag ? "active" : "";
return `<span class="tag ${isActive}" data-tag="${tag}">#${tag}<span class="tag-count">${count}</span></span>`;
})
.join("");
// Добавляем обработчики кликов для тегов
tagsContainer.querySelectorAll(".tag").forEach((tagElement) => {
tagElement.addEventListener(
"click",
async (event) => await handleTagClickMobile(event)
);
});
}
// Обработчик клика на тег в мобильном слайдере
async function handleTagClickMobile(event) {
const clickedTag = event.target.closest(".tag").dataset.tag;
// Если кликнули на тот же тег, снимаем фильтр
if (selectedTagFilter === clickedTag) {
selectedTagFilter = null;
} else {
selectedTagFilter = clickedTag;
}
// Перерисовываем заметки, теги и оба календаря
await renderNotes(allNotes);
renderTags();
renderTagsMobile();
renderCalendar();
renderCalendarMobile();
updateFilterIndicator();
}
// Инициализация мобильного слайдера
document.addEventListener("DOMContentLoaded", function () {
const mobileMenuBtn = document.getElementById("mobileMenuBtn");
const sidebarCloseBtn = document.getElementById("sidebarCloseBtn");
const mobileSidebar = document.getElementById("mobileSidebar");
const sidebarOverlay = document.getElementById("sidebarOverlay");
// Открытие слайдера при клике на кнопку меню
if (mobileMenuBtn) {
mobileMenuBtn.addEventListener("click", function () {
mobileSidebar.classList.add("open");
sidebarOverlay.classList.add("open");
document.body.style.overflow = "hidden";
});
}
// Закрытие слайдера при клике на кнопку закрытия
if (sidebarCloseBtn) {
sidebarCloseBtn.addEventListener("click", function () {
mobileSidebar.classList.remove("open");
sidebarOverlay.classList.remove("open");
document.body.style.overflow = "auto";
});
}
// Закрытие слайдера при клике на оверлей
if (sidebarOverlay) {
sidebarOverlay.addEventListener("click", function () {
mobileSidebar.classList.remove("open");
sidebarOverlay.classList.remove("open");
document.body.style.overflow = "auto";
});
}
// Инициализация мобильного поиска
initSearchMobile();
// Инициализация мобильного календаря
renderCalendarMobile();
renderTagsMobile();
// Обработчики для кнопок навигации мобильного календаря
const prevMonthBtnMobile = document.getElementById("prevMonthMobile");
const nextMonthBtnMobile = document.getElementById("nextMonthMobile");
if (prevMonthBtnMobile) {
prevMonthBtnMobile.addEventListener("click", function () {
currentDate.setMonth(currentDate.getMonth() - 1);
renderCalendar();
renderCalendarMobile();
});
}
if (nextMonthBtnMobile) {
nextMonthBtnMobile.addEventListener("click", function () {
currentDate.setMonth(currentDate.getMonth() + 1);
renderCalendar();
renderCalendarMobile();
});
}
});
// Функция для инициализации мобильного поиска
function initSearchMobile() {
const searchInput = document.getElementById("searchInputMobile");
const clearSearchBtn = document.getElementById("clearSearchBtnMobile");
if (!searchInput || !clearSearchBtn) return;
// Обработчик ввода в поле поиска с задержкой
let searchTimeout;
searchInput.addEventListener("input", function () {
clearTimeout(searchTimeout);
const query = this.value;
// Показываем/скрываем кнопку очистки
if (query.trim()) {
clearSearchBtn.style.display = "block";
} else {
clearSearchBtn.style.display = "none";
}
// Задержка перед поиском для оптимизации
searchTimeout = setTimeout(() => {
searchNotes(query);
// Обновляем основное поле поиска
const mainSearchInput = document.getElementById("searchInput");
if (mainSearchInput) {
mainSearchInput.value = query;
}
const mainClearSearchBtn = document.getElementById("clearSearchBtn");
if (mainClearSearchBtn) {
if (query.trim()) {
mainClearSearchBtn.style.display = "block";
} else {
mainClearSearchBtn.style.display = "none";
}
}
updateFilterIndicator();
}, 300);
});
// Обработчик клика на кнопку очистки поиска
clearSearchBtn.addEventListener("click", async function () {
searchInput.value = "";
this.style.display = "none";
searchQuery = "";
searchResults = [];
// Обновляем основное поле поиска
const mainSearchInput = document.getElementById("searchInput");
if (mainSearchInput) {
mainSearchInput.value = "";
}
const mainClearSearchBtn = document.getElementById("clearSearchBtn");
if (mainClearSearchBtn) {
mainClearSearchBtn.style.display = "none";
}
await renderNotes(allNotes);
updateFilterIndicator();
});
// Обработчик клавиши Escape для очистки поиска
searchInput.addEventListener("keydown", function (event) {
if (event.key === "Escape") {
this.value = "";
clearSearchBtn.style.display = "none";
searchQuery = "";
searchResults = [];
// Обновляем основное поле поиска
const mainSearchInput = document.getElementById("searchInput");
if (mainSearchInput) {
mainSearchInput.value = "";
}
const mainClearSearchBtn = document.getElementById("clearSearchBtn");
if (mainClearSearchBtn) {
mainClearSearchBtn.style.display = "none";
}
renderNotes(allNotes);
updateFilterIndicator();
}
});
}
// Логика переключения темы
function initThemeToggle() {
const themeToggleBtn = document.getElementById("theme-toggle-btn");
if (!themeToggleBtn) return;
// Загружаем сохраненную тему или используем системную
const savedTheme = localStorage.getItem("theme");
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
const currentTheme = savedTheme || systemTheme;
// Применяем тему
applyTheme(currentTheme);
// Обработчик клика на переключатель
themeToggleBtn.addEventListener("click", () => {
const currentTheme = document.documentElement.getAttribute("data-theme");
const newTheme = currentTheme === "dark" ? "light" : "dark";
applyTheme(newTheme);
localStorage.setItem("theme", newTheme);
});
// Слушаем изменения системной темы
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", (e) => {
if (!localStorage.getItem("theme")) {
applyTheme(e.matches ? "dark" : "light");
}
});
}
function applyTheme(theme) {
document.documentElement.setAttribute("data-theme", theme);
// Обновляем meta теги для PWA
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
if (themeColorMeta) {
themeColorMeta.setAttribute(
"content",
theme === "dark" ? "#1a1a1a" : "#007bff"
);
}
// Обновляем иконку переключателя
const themeToggleBtn = document.getElementById("theme-toggle-btn");
if (themeToggleBtn) {
const icon = themeToggleBtn.querySelector(".iconify");
if (icon) {
icon.setAttribute(
"data-icon",
theme === "dark" ? "mdi:weather-sunny" : "mdi:weather-night"
);
}
}
// Применяем тему к предпросмотру, если он открыт
if (isPreviewMode) {
applyThemeToPreview();
}
}
// Функция для проверки и применения видимости кнопок AI
async function updateAiButtonsVisibility() {
// Проверяем localStorage сначала для быстрого ответа
let aiEnabled = localStorage.getItem("ai_enabled");
// Если нет в localStorage, загружаем с сервера
if (aiEnabled === null) {
try {
const response = await fetch("/api/user/ai-settings");
if (response.ok) {
const settings = await response.json();
aiEnabled = settings.ai_enabled ? "1" : "0";
localStorage.setItem("ai_enabled", aiEnabled);
} else {
aiEnabled = "0"; // По умолчанию выключено
}
} catch (error) {
console.error("Ошибка загрузки настроек AI:", error);
aiEnabled = "0"; // По умолчанию выключено
}
}
const isEnabled = aiEnabled === "1";
// Показываем/скрываем кнопку AI в основном редакторе
const mainAiBtn = document.getElementById("aiImproveBtn");
if (mainAiBtn) {
mainAiBtn.style.display = isEnabled ? "" : "none";
}
// Показываем/скрываем кнопки AI в редакторах заметок
document.querySelectorAll(".btnAI").forEach((btn) => {
btn.style.display = isEnabled ? "" : "none";
});
}
// Инициализируем переключатель темы при загрузке страницы
document.addEventListener("DOMContentLoaded", () => {
initThemeToggle();
updateAiButtonsVisibility();
});
// Обновляем видимость кнопок AI когда пользователь возвращается на страницу
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
updateAiButtonsVisibility();
}
});