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