// Кэширование аватарки 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 = `

${title}

× `; // Создаем тело модального окна const modalBody = document.createElement("div"); modalBody.className = "modal-body"; modalBody.innerHTML = `

${message}

`; // Создаем футер с кнопками const modalFooter = document.createElement("div"); modalFooter.className = "modal-footer"; modalFooter.innerHTML = ` `; // Собираем модальное окно 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 = `${match.fullMatch}`; 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 = '
Нет тегов
'; return; } tagsContainer.innerHTML = sortedTags .map((tag) => { const count = tagCounts[tag]; const isActive = selectedTagFilter === tag ? "active" : ""; return `#${tag}${count}`; }) .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 = `Текст`; } else { // Если текст выделен, оборачиваем его в цветовой тег replacement = `${selected}`; } 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 = `Текст`; } else { // Если текст выделен, оборачиваем его в цветовой тег replacement = `${selected}`; } 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 = ' Обработка...'; 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 = '

Нет содержимого для предпросмотра

'; } // Меняем иконку кнопки previewBtn.innerHTML = ''; previewBtn.title = "Закрыть предпросмотр"; } else { // Возвращаемся к редактированию noteInput.style.display = "block"; notePreviewContainer.style.display = "none"; // Меняем иконку обратно previewBtn.innerHTML = ''; 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 = ` Preview
${fileName}
${fileSize} MB
`; 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 = `
${fileName}
${fileSize} MB
`; 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 = `
📤 Загрузка изображений...
${selectedImages.length} файл(ов)
`; 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 = `
📎 Загрузка файлов...
${selectedFiles.length} файл(ов)
`; 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 = "

Ошибка загрузки заметок

"; } finally { // Скрываем индикатор загрузки hideLoadingIndicator(); } } // Функция для показа индикатора загрузки function showLoadingIndicator() { if (!document.getElementById("loading-indicator")) { const loadingDiv = document.createElement("div"); loadingDiv.id = "loading-indicator"; loadingDiv.innerHTML = `
Загрузка заметок...
`; 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, '$1'); } // Настройка 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 `${text}`; } 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(/]*disabled[^>]*>/gi, "").trim(); // Создаем чекбокс БЕЗ disabled атрибута return `
  • ${cleanText}
  • \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 `${token.text}`; }, }; // Регистрируем расширение через 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 = `

    ${message}

    `; 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 = '
    '; noteImages.forEach((image) => { imagesHtml += `
    ${image.original_name}
    `; }); imagesHtml += "
    "; } // Используем файлы, которые уже пришли с заметкой const noteFiles = Array.isArray(note.files) ? note.files : []; let filesHtml = ""; if (noteFiles.length > 0) { filesHtml = '
    '; 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 += ` `; }); filesHtml += "
    "; } // Форматируем дату создания и изменения по локали устройства 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 )} | ${formatLocalDateTime( updated )}`; } else { dateDisplay = formatLocalDateTime(created); } } else { // Фолбэк для старых записей dateDisplay = `${note.date} ${note.time}`; } // Определяем класс для закрепленной заметки const pinnedClass = note.is_pinned ? " note-pinned" : ""; const pinIndicator = note.is_pinned ? 'Закреплено' : ""; const noteHtml = `
    ${dateDisplay}${pinIndicator}
    ${parsedContent}
    ${imagesHtml} ${filesHtml}
    `; 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 = ` `; 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 = ``; 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 = `Прикрепленные изображения:`; 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 = ` Новые изображения: `; 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 = `Прикрепленные файлы:`; 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 = `
    ${file.name}
    ${file.size}
    `; 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 = ` Новые файлы: `; 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 = ' Помощь ИИ'; 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 = ` Preview
    ${file.name}
    `; 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 = `
    ${file.name}
    ${fileSize} MB
    `; 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 = "Предпросмотр:"; 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 = '

    Нет содержимого для предпросмотра

    '; } // Меняем иконку кнопки btn.innerHTML = ''; btn.title = "Закрыть предпросмотр"; } else { // Возвращаемся к редактированию textarea.style.display = "block"; editPreviewContainer.style.display = "none"; // Меняем иконку обратно btn.innerHTML = ''; 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 = ' Обработка...'; 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 = `
    💾 Сохранение заметки...
    ${ attachmentsInfo.length > 0 ? `
    + ${attachmentsInfo.join( ", " )}
    ` : "" } `; 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 = ''; 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( ", " )} `; // Добавляем обработчик клика для кнопки сброса 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 = '
    Нет тегов
    '; return; } tagsContainer.innerHTML = sortedTags .map((tag) => { const count = tagCounts[tag]; const isActive = selectedTagFilter === tag ? "active" : ""; return `#${tag}${count}`; }) .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(); } });