`;
// Создаем футер с кнопками
const modalFooter = document.createElement("div");
modalFooter.className = "modal-footer";
modalFooter.innerHTML = `
`;
// Собираем модальное окно
modalContent.appendChild(modalHeader);
modalContent.appendChild(modalBody);
modalContent.appendChild(modalFooter);
modal.appendChild(modalContent);
// Добавляем на страницу
document.body.appendChild(modal);
// Функция закрытия
function closeModal() {
modal.style.display = "none";
if (modal.parentNode) {
modal.parentNode.removeChild(modal);
}
}
// Обработчики событий
const closeBtn = modalHeader.querySelector(".modal-close");
const cancelBtn = modalFooter.querySelector("#cancelBtn");
const confirmBtn = modalFooter.querySelector("#confirmBtn");
closeBtn.addEventListener("click", () => {
closeModal();
resolve(false);
});
cancelBtn.addEventListener("click", () => {
closeModal();
resolve(false);
});
confirmBtn.addEventListener("click", () => {
closeModal();
resolve(true);
});
// Закрытие при клике вне модального окна
modal.addEventListener("click", (e) => {
if (e.target === modal) {
closeModal();
resolve(false);
}
});
// Закрытие по Escape
const handleEscape = (e) => {
if (e.key === "Escape") {
closeModal();
resolve(false);
document.removeEventListener("keydown", handleEscape);
}
};
document.addEventListener("keydown", handleEscape);
});
}
// Функция для кэширования аватарки
async function cacheAvatar(avatarUrl) {
try {
const response = await fetch(avatarUrl);
if (!response.ok) return false;
const blob = await response.blob();
const base64 = await blobToBase64(blob);
const cacheData = {
base64: base64,
timestamp: Date.now(),
url: avatarUrl,
};
localStorage.setItem(AVATAR_CACHE_KEY, JSON.stringify(cacheData));
localStorage.setItem(AVATAR_TIMESTAMP_KEY, Date.now().toString());
return true;
} catch (error) {
console.error("Ошибка кэширования аватарки:", error);
return false;
}
}
// Функция для получения аватарки из кэша
function getCachedAvatar() {
try {
const cacheData = localStorage.getItem(AVATAR_CACHE_KEY);
const timestamp = localStorage.getItem(AVATAR_TIMESTAMP_KEY);
if (!cacheData || !timestamp) return null;
const data = JSON.parse(cacheData);
const cacheAge = Date.now() - parseInt(timestamp);
// Кэш действителен 24 часа (86400000 мс)
if (cacheAge > 24 * 60 * 60 * 1000) {
clearAvatarCache();
return null;
}
return data;
} catch (error) {
console.error("Ошибка получения аватарки из кэша:", error);
clearAvatarCache();
return null;
}
}
// Функция для очистки кэша аватарки
function clearAvatarCache() {
localStorage.removeItem(AVATAR_CACHE_KEY);
localStorage.removeItem(AVATAR_TIMESTAMP_KEY);
}
// Преобразование Blob в base64
function blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
// DOM элементы
const noteInput = document.getElementById("noteInput");
const saveBtn = document.getElementById("saveBtn");
const notesList = document.getElementById("notes-container");
// Получаем кнопки markdown
const boldBtn = document.getElementById("boldBtn");
const italicBtn = document.getElementById("italicBtn");
const strikethroughBtn = document.getElementById("strikethroughBtn");
const colorBtn = document.getElementById("colorBtn");
const headerBtn = document.getElementById("headerBtn");
const headerDropdown = document.getElementById("headerDropdown");
const listBtn = document.getElementById("listBtn");
const numberedListBtn = document.getElementById("numberedListBtn");
const quoteBtn = document.getElementById("quoteBtn");
const codeBtn = document.getElementById("codeBtn");
const linkBtn = document.getElementById("linkBtn");
const checkboxBtn = document.getElementById("checkboxBtn");
const imageBtn = document.getElementById("imageBtn");
const spoilerBtn = document.getElementById("spoilerBtn");
const previewBtn = document.getElementById("previewBtn");
const aiImproveBtn = document.getElementById("aiImproveBtn");
// Кнопка настроек
const settingsBtn = document.getElementById("settings-btn");
// Элементы для загрузки изображений
const imageInput = document.getElementById("imageInput");
const imagePreviewContainer = document.getElementById("imagePreviewContainer");
const imagePreviewList = document.getElementById("imagePreviewList");
const clearImagesBtn = document.getElementById("clearImagesBtn");
// Элементы для загрузки файлов
const fileBtn = document.getElementById("fileBtn");
const fileInput = document.getElementById("fileInput");
const filePreviewContainer = document.getElementById("filePreviewContainer");
const filePreviewList = document.getElementById("filePreviewList");
const clearFilesBtn = document.getElementById("clearFilesBtn");
// Элементы для предпросмотра заметки
const notePreviewContainer = document.getElementById("notePreviewContainer");
const notePreviewContent = document.getElementById("notePreviewContent");
// Модальное окно для просмотра изображений
const imageModal = document.getElementById("imageModal");
const modalImage = document.getElementById("modalImage");
const modalClose = document.querySelector(".image-modal-close");
// Массив для хранения выбранных изображений
let selectedImages = [];
let selectedFiles = [];
// Флаг режима предпросмотра
let isPreviewMode = false;
// Глобальные переменные для заметок и фильтрации
let allNotes = [];
let selectedDateFilter = null;
let selectedTagFilter = null;
let searchQuery = "";
let searchResults = [];
let notesCache = null; // Кэш для заметок
let lastLoadTime = 0; // Время последней загрузки
// Lazy loading для изображений
function initLazyLoading() {
// Проверяем поддержку Intersection Observer API
if ("IntersectionObserver" in window) {
const imageObserver = new IntersectionObserver(
(entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
// Если у изображения есть data-src, загружаем его
if (img.dataset.src) {
img.src = img.dataset.src;
img.removeAttribute("data-src");
}
img.classList.remove("lazy");
observer.unobserve(img);
}
});
},
{
rootMargin: "50px 0px", // Загружать изображения за 50px до появления в viewport
threshold: 0.01,
}
);
// Наблюдаем за всеми изображениями с классом lazy
document.querySelectorAll("img.lazy").forEach((img) => {
imageObserver.observe(img);
});
} else {
// Fallback для старых браузеров - просто показываем все изображения
document.querySelectorAll("img.lazy").forEach((img) => {
if (img.dataset.src) {
img.src = img.dataset.src;
img.removeAttribute("data-src");
}
img.classList.remove("lazy");
});
}
}
// Функция для получения текущей даты и времени
function getFormattedDateTime() {
let now = new Date();
let day = String(now.getDate()).padStart(2, "0");
let month = String(now.getMonth() + 1).padStart(2, "0");
let year = now.getFullYear();
let hours = String(now.getHours()).padStart(2, "0");
let minutes = String(now.getMinutes()).padStart(2, "0");
return {
date: `${day}.${month}.${year}`,
time: `${hours}:${minutes}`,
};
}
// Вспомогательные функции для корректной работы с временем (UTC -> локаль)
function parseSQLiteUtc(ts) {
return new Date(ts.replace(" ", "T") + "Z");
}
function formatLocalDateTime(date) {
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
function formatLocalDateOnly(date) {
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(date);
}
// Функция для авторасширения текстового поля
function autoExpandTextarea(textarea) {
textarea.style.height = "auto";
textarea.style.height = textarea.scrollHeight + "px";
}
// Функция для извлечения тегов из текста заметки
function extractTags(content) {
const tagRegex = /#([а-яё\w]+)/gi;
const tags = [];
let match;
while ((match = tagRegex.exec(content)) !== null) {
const matchIndex = match.index;
// Проверяем, не находится ли # внутри HTML-атрибута
const beforeContext = content.substring(
Math.max(0, matchIndex - 100),
matchIndex
);
const afterContext = content.substring(
matchIndex + match[0].length,
Math.min(content.length, matchIndex + match[0].length + 100)
);
// Проверяем, есть ли признаки HTML-атрибута (например, style="color: #...)
const lastOpenTag = beforeContext.lastIndexOf("<");
const lastCloseTag = beforeContext.lastIndexOf(">");
// Если внутри HTML-тега, пропускаем
if (lastOpenTag > lastCloseTag) {
continue;
}
// Проверяем наличие = и кавычки перед #
const lastQuote = Math.max(
beforeContext.lastIndexOf('"'),
beforeContext.lastIndexOf("'")
);
const lastEquals = beforeContext.lastIndexOf("=");
// Если перед # есть = и кавычка, и после есть закрывающая кавычка
if (lastEquals > -1 && lastQuote > lastEquals) {
const nextQuote = Math.min(
afterContext.indexOf('"') !== -1 ? afterContext.indexOf('"') : Infinity,
afterContext.indexOf("'") !== -1 ? afterContext.indexOf("'") : Infinity
);
if (nextQuote !== Infinity) {
continue; // Пропускаем, это часть HTML-атрибута
}
}
const tag = match[1].toLowerCase();
if (!tags.includes(tag)) {
tags.push(tag);
}
}
return tags;
}
// Функция для преобразования тегов в заметках в кликабельные элементы
function makeTagsClickable(content) {
// Сначала находим все теги, которые еще не обернуты в HTML
const tagRegex = /#([а-яё\w]+)/gi;
let result = content;
let match;
// Создаем массив всех совпадений с их позициями
const matches = [];
while ((match = tagRegex.exec(content)) !== null) {
matches.push({
fullMatch: match[0],
tag: match[1],
index: match.index,
});
}
// Обрабатываем совпадения в обратном порядке, чтобы не сбить индексы
for (let i = matches.length - 1; i >= 0; i--) {
const match = matches[i];
const beforeTag = result.substring(0, match.index);
const afterTag = result.substring(match.index + match.fullMatch.length);
// Проверяем, не находится ли тег уже внутри HTML-тега
const lastOpenTag = beforeTag.lastIndexOf("<");
const lastCloseTag = beforeTag.lastIndexOf(">");
// Если последний открывающий тег идет после последнего закрывающего, значит мы внутри HTML-тега
if (lastOpenTag > lastCloseTag) {
continue; // Пропускаем этот тег
}
// Дополнительная проверка: не находится ли # внутри HTML-атрибута (например, style="color: #ff0000")
// Ищем последний символ кавычки перед текущей позицией
const beforeContext = beforeTag.substring(Math.max(0, match.index - 100));
const lastQuote = Math.max(
beforeContext.lastIndexOf('"'),
beforeContext.lastIndexOf("'")
);
const lastEquals = beforeContext.lastIndexOf("=");
// Если перед # есть = и кавычка, и нет закрывающей кавычки после =, то # находится в атрибуте
if (lastEquals > -1 && lastQuote > lastEquals) {
const afterContext = afterTag.substring(
0,
Math.min(100, afterTag.length)
);
const nextQuote = Math.min(
afterContext.indexOf('"') !== -1 ? afterContext.indexOf('"') : Infinity,
afterContext.indexOf("'") !== -1 ? afterContext.indexOf("'") : Infinity
);
// Если нашли закрывающую кавычку, значит # внутри атрибута
if (nextQuote !== Infinity) {
continue; // Пропускаем этот тег
}
}
// Заменяем тег на кликабельный элемент
const replacement = `${match.fullMatch}`;
result = beforeTag + replacement + afterTag;
}
return result;
}
// Функция для получения всех уникальных тегов из заметок
function getAllTags(notes) {
const tagCounts = {};
notes.forEach((note) => {
const tags = extractTags(note.content);
tags.forEach((tag) => {
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
});
});
return tagCounts;
}
// Функция для отображения тегов
function renderTags() {
const tagsContainer = document.getElementById("tagsContainer");
if (!tagsContainer) return;
const tagCounts = getAllTags(allNotes);
const sortedTags = Object.keys(tagCounts).sort();
if (sortedTags.length === 0) {
tagsContainer.innerHTML =
'
Нет тегов
';
return;
}
tagsContainer.innerHTML = sortedTags
.map((tag) => {
const count = tagCounts[tag];
const isActive = selectedTagFilter === tag ? "active" : "";
return `#${tag}${count}`;
})
.join("");
// Добавляем обработчики кликов для тегов
tagsContainer.querySelectorAll(".tag").forEach((tagElement) => {
tagElement.addEventListener(
"click",
async (event) => await handleTagClick(event)
);
});
}
// Обработчик клика на тег
async function handleTagClick(event) {
const clickedTag = event.target.closest(".tag").dataset.tag;
// Если кликнули на тот же тег, снимаем фильтр
if (selectedTagFilter === clickedTag) {
selectedTagFilter = null;
} else {
selectedTagFilter = clickedTag;
}
// Перерисовываем заметки и теги
await renderNotes(allNotes);
renderTags();
updateFilterIndicator();
}
// Привязываем авторасширение к текстовому полю для создания заметки
noteInput.addEventListener("input", function () {
autoExpandTextarea(noteInput);
});
// Изначально запускаем для установки правильной высоты
autoExpandTextarea(noteInput);
// Функция для вставки цветового тега
function insertColorTag() {
// Создаем диалог выбора цвета
const colorDialog = document.createElement("input");
colorDialog.type = "color";
colorDialog.style.display = "none";
document.body.appendChild(colorDialog);
// Обработчик изменения цвета
colorDialog.addEventListener("change", function () {
const selectedColor = this.value;
insertColorMarkdown(selectedColor);
document.body.removeChild(this);
});
// Обработчик отмены
colorDialog.addEventListener("cancel", function () {
document.body.removeChild(this);
});
// Показываем диалог выбора цвета
colorDialog.click();
}
// Функция для вставки цветового markdown
function insertColorMarkdown(color) {
const start = noteInput.selectionStart;
const end = noteInput.selectionEnd;
const text = noteInput.value;
const before = text.substring(0, start);
const selected = text.substring(start, end);
const after = text.substring(end);
let replacement;
if (selected.trim() === "") {
// Если текст не выделен, вставляем шаблон
replacement = `Текст`;
} else {
// Если текст выделен, оборачиваем его в цветовой тег
replacement = `${selected}`;
}
noteInput.value = before + replacement + after;
// Устанавливаем курсор после вставленного текста
const cursorPosition = start + replacement.length;
noteInput.setSelectionRange(cursorPosition, cursorPosition);
noteInput.focus();
}
// Функция для вставки markdown
function insertSpoiler() {
const start = noteInput.selectionStart;
const end = noteInput.selectionEnd;
const text = noteInput.value;
const before = text.substring(0, start);
const selected = text.substring(start, end);
const after = text.substring(end);
let newText;
let newCursorPos;
if (selected) {
// Если есть выделенный текст, оборачиваем его в спойлер
newText = before + "||" + selected + "||" + after;
newCursorPos = start + selected.length + 4; // После выделенного текста
} else {
// Если нет выделенного текста, вставляем пустой спойлер
newText = before + "||скрытый текст||" + after;
newCursorPos = start + 2; // Внутри спойлера для редактирования
}
noteInput.value = newText;
noteInput.setSelectionRange(newCursorPos, newCursorPos);
noteInput.focus();
}
function insertMarkdown(tag) {
const start = noteInput.selectionStart;
const end = noteInput.selectionEnd;
const text = noteInput.value;
const before = text.substring(0, start);
const selected = text.substring(start, end);
const after = text.substring(end);
// Мультистрочные преобразования списков (toggle)
if (
(tag === "1. " || tag === "- " || tag === "- [ ] ") &&
selected.includes("\n")
) {
const mode =
tag === "1. " ? "ordered" : tag === "- " ? "unordered" : "todo";
const transformed = transformSelection(noteInput, mode);
noteInput.value = transformed.newValue;
noteInput.setSelectionRange(transformed.newSelStart, transformed.newSelEnd);
noteInput.focus();
return;
}
// Определяем, какие теги оборачивают текст (нуждаются в двойных тегах)
const wrappingTags = ["**", "*", "`"];
const isWrappingTag = wrappingTags.some((wrapTag) => tag.startsWith(wrapTag));
if (isWrappingTag && selected.startsWith(tag) && selected.endsWith(tag)) {
// Если оборачивающие теги уже есть, удаляем их
noteInput.value = `${before}${selected.slice(
tag.length,
-tag.length
)}${after}`;
noteInput.setSelectionRange(start, end - 2 * tag.length);
} else if (!isWrappingTag && selected.startsWith(tag)) {
// Если одинарные теги (заголовки, списки) уже есть, удаляем их
noteInput.value = `${before}${selected.slice(tag.length)}${after}`;
noteInput.setSelectionRange(start, end - tag.length);
} else if (selected.trim() === "") {
// Если текст не выделен
if (tag === "[Текст ссылки](URL)") {
// Для ссылок создаем шаблон с двумя кавычками
noteInput.value = `${before}[Текст ссылки](URL)${after}`;
const cursorPosition = start + 1; // Помещаем курсор внутрь текста ссылки
noteInput.setSelectionRange(cursorPosition, cursorPosition + 12);
} else if (
tag === "- " ||
tag === "1. " ||
tag === "> " ||
/^#{1,6} $/.test(tag) ||
tag === "- [ ] "
) {
// Для списка, цитаты, заголовка и чекбокса помещаем курсор после тега
noteInput.value = `${before}${tag}${after}`;
const cursorPosition = start + tag.length;
noteInput.setSelectionRange(cursorPosition, cursorPosition);
} else {
// Для остальных типов создаем два тега
noteInput.value = `${before}${tag}${tag}${after}`;
const cursorPosition = start + tag.length;
noteInput.setSelectionRange(cursorPosition, cursorPosition);
}
} else {
// Если текст выделен
if (tag === "[Текст ссылки](URL)") {
// Для ссылок используем выделенный текст вместо "Текст ссылки"
noteInput.value = `${before}[${selected}](URL)${after}`;
const cursorPosition = start + selected.length + 3; // Помещаем курсор в URL
noteInput.setSelectionRange(cursorPosition, cursorPosition + 3);
} else if (
tag === "- " ||
tag === "1. " ||
tag === "> " ||
/^#{1,6} $/.test(tag) ||
tag === "- [ ] "
) {
// Для списка, цитаты, заголовка и чекбокса добавляем тег перед выделенным текстом
noteInput.value = `${before}${tag}${selected}${after}`;
const cursorPosition = start + tag.length + selected.length;
noteInput.setSelectionRange(cursorPosition, cursorPosition);
} else {
// Для остальных типов оборачиваем выделенный текст
noteInput.value = `${before}${tag}${selected}${tag}${after}`;
const cursorPosition = start + tag.length + selected.length + tag.length;
noteInput.setSelectionRange(cursorPosition, cursorPosition);
}
}
noteInput.focus();
}
// Функция для вставки цветового тега в режиме редактирования
function insertColorTagForEdit(textarea) {
// Создаем диалог выбора цвета
const colorDialog = document.createElement("input");
colorDialog.type = "color";
colorDialog.style.display = "none";
document.body.appendChild(colorDialog);
// Обработчик изменения цвета
colorDialog.addEventListener("change", function () {
const selectedColor = this.value;
insertColorMarkdownForEdit(textarea, selectedColor);
document.body.removeChild(this);
});
// Обработчик отмены
colorDialog.addEventListener("cancel", function () {
document.body.removeChild(this);
});
// Показываем диалог выбора цвета
colorDialog.click();
}
// Функция для вставки цветового markdown в режиме редактирования
function insertColorMarkdownForEdit(textarea, color) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const before = text.substring(0, start);
const selected = text.substring(start, end);
const after = text.substring(end);
let replacement;
if (selected.trim() === "") {
// Если текст не выделен, вставляем шаблон
replacement = `Текст`;
} else {
// Если текст выделен, оборачиваем его в цветовой тег
replacement = `${selected}`;
}
textarea.value = before + replacement + after;
// Устанавливаем курсор после вставленного текста
const cursorPosition = start + replacement.length;
textarea.setSelectionRange(cursorPosition, cursorPosition);
textarea.focus();
}
// Функция для вставки markdown в режиме редактирования
function insertMarkdownForEdit(textarea, tag) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const before = text.substring(0, start);
const selected = text.substring(start, end);
const after = text.substring(end);
// Мультистрочные преобразования списков (toggle)
if (
(tag === "1. " || tag === "- " || tag === "- [ ] ") &&
selected.includes("\n")
) {
const mode =
tag === "1. " ? "ordered" : tag === "- " ? "unordered" : "todo";
const transformed = transformSelection(textarea, mode);
textarea.value = transformed.newValue;
textarea.setSelectionRange(transformed.newSelStart, transformed.newSelEnd);
textarea.focus();
return;
}
// Определяем, какие теги оборачивают текст (нуждаются в двойных тегах)
const wrappingTags = ["**", "*", "`"];
const isWrappingTag = wrappingTags.some((wrapTag) => tag.startsWith(wrapTag));
if (isWrappingTag && selected.startsWith(tag) && selected.endsWith(tag)) {
// Если оборачивающие теги уже есть, удаляем их
textarea.value = `${before}${selected.slice(
tag.length,
-tag.length
)}${after}`;
textarea.setSelectionRange(start, end - 2 * tag.length);
} else if (!isWrappingTag && selected.startsWith(tag)) {
// Если одинарные теги (заголовки, списки) уже есть, удаляем их
textarea.value = `${before}${selected.slice(tag.length)}${after}`;
textarea.setSelectionRange(start, end - tag.length);
} else if (selected.trim() === "") {
// Если текст не выделен
if (tag === "[Текст ссылки](URL)") {
// Для ссылок создаем шаблон с двумя кавычками
textarea.value = `${before}[Текст ссылки](URL)${after}`;
const cursorPosition = start + 1; // Помещаем курсор внутрь текста ссылки
textarea.setSelectionRange(cursorPosition, cursorPosition + 12);
} else if (
tag === "- " ||
tag === "1. " ||
tag === "> " ||
/^#{1,6} $/.test(tag) ||
tag === "- [ ] "
) {
// Для списка, цитаты, заголовка и чекбокса помещаем курсор после тега
textarea.value = `${before}${tag}${after}`;
const cursorPosition = start + tag.length;
textarea.setSelectionRange(cursorPosition, cursorPosition);
} else {
// Для остальных типов создаем два тега
textarea.value = `${before}${tag}${tag}${after}`;
const cursorPosition = start + tag.length;
textarea.setSelectionRange(cursorPosition, cursorPosition);
}
} else {
// Если текст выделен
if (tag === "[Текст ссылки](URL)") {
// Для ссылок используем выделенный текст вместо "Текст ссылки"
textarea.value = `${before}[${selected}](URL)${after}`;
const cursorPosition = start + selected.length + 3; // Помещаем курсор в URL
textarea.setSelectionRange(cursorPosition, cursorPosition + 3);
} else if (
tag === "- " ||
tag === "1. " ||
tag === "> " ||
/^#{1,6} $/.test(tag) ||
tag === "- [ ] "
) {
// Для списка, цитаты, заголовка и чекбокса добавляем тег перед выделенным текстом
textarea.value = `${before}${tag}${selected}${after}`;
const cursorPosition = start + tag.length + selected.length;
textarea.setSelectionRange(cursorPosition, cursorPosition);
} else {
// Для остальных типов оборачиваем выделенный текст
textarea.value = `${before}${tag}${selected}${tag}${after}`;
const cursorPosition = start + tag.length + selected.length + tag.length;
textarea.setSelectionRange(cursorPosition, cursorPosition);
}
}
textarea.focus();
}
// Функция для вставки спойлера в режиме редактирования
function insertSpoilerForEdit(textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const before = text.substring(0, start);
const selected = text.substring(start, end);
const after = text.substring(end);
let newText;
let newCursorPos;
if (selected) {
newText = before + "||" + selected + "||" + after;
newCursorPos = start + selected.length + 4;
} else {
newText = before + "||скрытый текст||" + after;
newCursorPos = start + 2;
}
textarea.value = newText;
textarea.setSelectionRange(newCursorPos, newCursorPos);
textarea.focus();
}
// ==================== МУЛЬТИСТРОЧНЫЕ СПИСКИ (TOGGLE) ====================
function transformSelection(textarea, mode) {
const fullText = textarea.value;
const selStart = textarea.selectionStart;
const selEnd = textarea.selectionEnd;
// Расширяем до границ строк
let blockStart = fullText.lastIndexOf("\n", selStart - 1);
blockStart = blockStart === -1 ? 0 : blockStart + 1;
let blockEnd = fullText.indexOf("\n", selEnd);
blockEnd = blockEnd === -1 ? fullText.length : blockEnd;
const block = fullText.substring(blockStart, blockEnd);
const lines = block.split("\n");
const orderedRe = /^(\s*)(\d+)\.\s/;
const unorderedRe = /^(\s*)([-*+])\s/;
const todoRe = /^(\s*)- \[( |x)\] \s?/i;
const anyListPrefixRe = /^(\s*)(- \[(?: |x)\]\s?|[-*+]\s|\d+\.\s)/i;
function stripAnyPrefix(line) {
const m = line.match(anyListPrefixRe);
if (!m) return line;
return line.slice((m[1] + (m[2] || "")).length);
}
function toggleOrdered(inputLines) {
const nonEmpty = inputLines.filter((l) => l.trim() !== "");
const allHave =
nonEmpty.length > 0 && nonEmpty.every((l) => orderedRe.test(l));
if (allHave) {
return inputLines.map((l) => {
if (!l.trim()) return l;
const m = l.match(orderedRe);
if (!m) return l;
return m[1] + l.slice(m[0].length);
});
}
let index = 1;
return inputLines.map((l) => {
if (!l.trim()) return l;
// Снимаем любые префиксы и нумеруем заново
const indent = l.match(/^\s*/)?.[0] || "";
const content = stripAnyPrefix(l.trimStart());
const numbered = `${indent}${index}. ${content}`;
index += 1;
return numbered;
});
}
function toggleUnordered(inputLines) {
const nonEmpty = inputLines.filter((l) => l.trim() !== "");
const allHave =
nonEmpty.length > 0 && nonEmpty.every((l) => unorderedRe.test(l));
if (allHave) {
return inputLines.map((l) => {
if (!l.trim()) return l;
const m = l.match(unorderedRe);
if (!m) return l;
return m[1] + l.slice(m[0].length);
});
}
return inputLines.map((l) => {
if (!l.trim()) return l;
const indent = l.match(/^\s*/)?.[0] || "";
const content = stripAnyPrefix(l.trimStart());
return `${indent}- ${content}`;
});
}
function toggleTodo(inputLines) {
const nonEmpty = inputLines.filter((l) => l.trim() !== "");
const allHave =
nonEmpty.length > 0 && nonEmpty.every((l) => todoRe.test(l));
if (allHave) {
return inputLines.map((l) => {
if (!l.trim()) return l;
const m = l.match(todoRe);
if (!m) return l;
return m[1] + l.slice(m[0].length);
});
}
return inputLines.map((l) => {
if (!l.trim()) return l;
const indent = l.match(/^\s*/)?.[0] || "";
const content = stripAnyPrefix(l.trimStart());
return `${indent}- [ ] ${content}`;
});
}
let newLines;
if (mode === "ordered") newLines = toggleOrdered(lines);
else if (mode === "unordered") newLines = toggleUnordered(lines);
else newLines = toggleTodo(lines);
const newBlock = newLines.join("\n");
const newValue =
fullText.slice(0, blockStart) + newBlock + fullText.slice(blockEnd);
const newSelStart = blockStart;
const newSelEnd = blockStart + newBlock.length;
return { newValue, newSelStart, newSelEnd };
}
// Обработчики для кнопок markdown
boldBtn.addEventListener("click", function () {
insertMarkdown("**");
});
italicBtn.addEventListener("click", function () {
insertMarkdown("*");
});
strikethroughBtn.addEventListener("click", function () {
insertMarkdown("~~");
});
colorBtn.addEventListener("click", function () {
insertColorTag();
});
// Обработчик кнопки заголовка - открываем выпадающее меню
headerBtn.addEventListener("click", function (event) {
event.stopPropagation();
// Проверяем позицию и корректируем если нужно
const rect = headerDropdown.getBoundingClientRect();
const viewportWidth = window.innerWidth;
// Если меню выходит за правую границу, позиционируем его слева
if (rect.right > viewportWidth) {
headerDropdown.style.left = "auto";
headerDropdown.style.right = "0";
} else {
headerDropdown.style.left = "0";
headerDropdown.style.right = "auto";
}
headerDropdown.classList.toggle("show");
});
// Обработчики для пунктов выпадающего меню
headerDropdown.querySelectorAll("button").forEach((btn) => {
btn.addEventListener("click", function (event) {
event.stopPropagation();
const level = this.dataset.level;
const headerTag = "#".repeat(parseInt(level)) + " ";
insertMarkdown(headerTag);
headerDropdown.classList.remove("show");
});
});
// Закрытие выпадающего меню при клике вне его
document.addEventListener("click", function () {
headerDropdown.classList.remove("show");
});
listBtn.addEventListener("click", function () {
insertMarkdown("- ");
});
numberedListBtn.addEventListener("click", function () {
insertMarkdown("1. ");
});
quoteBtn.addEventListener("click", function () {
insertMarkdown("> ");
});
codeBtn.addEventListener("click", function () {
insertMarkdown("`");
});
linkBtn.addEventListener("click", function () {
insertMarkdown("[Текст ссылки](URL)");
});
checkboxBtn.addEventListener("click", function () {
insertMarkdown("- [ ] ");
});
spoilerBtn.addEventListener("click", function () {
insertSpoiler();
});
// Обработчик для кнопки загрузки изображений
imageBtn.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
imageInput.click();
});
// Дополнительный обработчик для touch событий на мобильных устройствах
imageBtn.addEventListener("touchend", function (event) {
event.preventDefault();
event.stopPropagation();
imageInput.click();
});
// Обработчик для кнопки прикрепления файлов
fileBtn.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
fileInput.click();
});
fileBtn.addEventListener("touchend", function (event) {
event.preventDefault();
event.stopPropagation();
fileInput.click();
});
// Обработчик для кнопки предпросмотра
previewBtn.addEventListener("click", function () {
togglePreview();
});
// Обработчик для кнопки улучшения через AI
aiImproveBtn.addEventListener("click", async function () {
const content = noteInput.value.trim();
if (!content) {
showNotification("Введите текст для улучшения", "warning");
return;
}
// Показываем индикатор загрузки
const originalHTML = aiImproveBtn.innerHTML;
const originalTitle = aiImproveBtn.title;
aiImproveBtn.disabled = true;
aiImproveBtn.innerHTML =
' Обработка...';
aiImproveBtn.title = "Обработка...";
try {
const response = await fetch("/api/ai/improve", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ text: content }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Ошибка улучшения текста");
}
// Заменяем текст на улучшенный
noteInput.value = data.improvedText;
// Авторасширяем textarea
autoExpandTextarea(noteInput);
showNotification("Текст успешно улучшен!", "success");
} catch (error) {
console.error("Ошибка улучшения текста:", error);
showNotification(error.message || "Ошибка улучшения текста", "error");
} finally {
// Восстанавливаем кнопку
aiImproveBtn.disabled = false;
aiImproveBtn.innerHTML = originalHTML;
aiImproveBtn.title = originalTitle;
}
});
// Функция переключения режима предпросмотра
function togglePreview() {
isPreviewMode = !isPreviewMode;
if (isPreviewMode) {
// Показываем предпросмотр
noteInput.style.display = "none";
notePreviewContainer.style.display = "block";
// Получаем содержимое и рендерим его
const content = noteInput.value;
if (content.trim()) {
// Парсим markdown и делаем теги кликабельными
const htmlContent = marked.parse(content);
const contentWithTags = makeTagsClickable(htmlContent);
notePreviewContent.innerHTML = contentWithTags;
// Применяем текущую тему к предпросмотру
applyThemeToPreview();
// Инициализируем lazy loading для изображений в превью
setTimeout(() => {
initLazyLoading();
}, 0);
} else {
notePreviewContent.innerHTML =
'
Нет содержимого для предпросмотра
';
}
// Меняем иконку кнопки
previewBtn.innerHTML =
'';
previewBtn.title = "Закрыть предпросмотр";
} else {
// Возвращаемся к редактированию
noteInput.style.display = "block";
notePreviewContainer.style.display = "none";
// Меняем иконку обратно
previewBtn.innerHTML = '';
previewBtn.title = "Предпросмотр";
}
}
// Функция применения темы к предпросмотру
function applyThemeToPreview() {
if (!notePreviewContainer || notePreviewContainer.style.display === "none") {
return;
}
const currentTheme = document.documentElement.getAttribute("data-theme");
// Применяем тему к контейнеру предпросмотра
if (currentTheme === "dark") {
notePreviewContainer.setAttribute("data-theme", "dark");
} else {
notePreviewContainer.removeAttribute("data-theme");
}
// Обновляем стили для элементов внутри предпросмотра
const previewElements = notePreviewContent.querySelectorAll("*");
previewElements.forEach((element) => {
// Применяем тему к элементам кода
if (element.tagName === "CODE" || element.tagName === "PRE") {
if (currentTheme === "dark") {
element.style.backgroundColor = "var(--bg-quaternary)";
element.style.color = "#e6e6e6";
element.style.border = "1px solid var(--border-primary)";
} else {
element.style.backgroundColor = "var(--bg-quaternary)";
element.style.color = "var(--text-primary)";
element.style.border = "1px solid var(--border-primary)";
}
}
// Применяем тему к цитатам
if (element.tagName === "BLOCKQUOTE") {
if (currentTheme === "dark") {
element.style.backgroundColor = "var(--bg-tertiary)";
element.style.borderLeftColor = "var(--accent-color, #4a9eff)";
element.style.color = "var(--text-secondary)";
} else {
element.style.backgroundColor = "var(--bg-tertiary)";
element.style.borderLeftColor = "var(--accent-color, #007bff)";
element.style.color = "var(--text-secondary)";
}
}
});
}
// Функция применения темы к предпросмотру в режиме редактирования
function applyThemeToEditPreview(editPreviewContainer, editPreviewContent) {
if (!editPreviewContainer || editPreviewContainer.style.display === "none") {
return;
}
const currentTheme = document.documentElement.getAttribute("data-theme");
// Применяем тему к контейнеру предпросмотра редактирования
if (currentTheme === "dark") {
editPreviewContainer.setAttribute("data-theme", "dark");
} else {
editPreviewContainer.removeAttribute("data-theme");
}
// Обновляем стили для элементов внутри предпросмотра редактирования
const previewElements = editPreviewContent.querySelectorAll("*");
previewElements.forEach((element) => {
// Применяем тему к элементам кода
if (element.tagName === "CODE" || element.tagName === "PRE") {
if (currentTheme === "dark") {
element.style.backgroundColor = "var(--bg-quaternary)";
element.style.color = "#e6e6e6";
element.style.border = "1px solid var(--border-primary)";
} else {
element.style.backgroundColor = "var(--bg-quaternary)";
element.style.color = "var(--text-primary)";
element.style.border = "1px solid var(--border-primary)";
}
}
// Применяем тему к цитатам
if (element.tagName === "BLOCKQUOTE") {
if (currentTheme === "dark") {
element.style.backgroundColor = "var(--bg-tertiary)";
element.style.borderLeftColor = "var(--accent-color, #4a9eff)";
element.style.color = "var(--text-secondary)";
} else {
element.style.backgroundColor = "var(--bg-tertiary)";
element.style.borderLeftColor = "var(--accent-color, #007bff)";
element.style.color = "var(--text-secondary)";
}
}
});
}
// Обработчик выбора файлов
imageInput.addEventListener("change", function (event) {
const files = Array.from(event.target.files);
let addedCount = 0;
files.forEach((file) => {
if (file.type.startsWith("image/")) {
// Проверяем размер файла (максимум 10MB)
if (file.size > 10 * 1024 * 1024) {
showNotification(
`Файл "${file.name}" слишком большой. Максимальный размер: 10MB`,
"error"
);
return;
}
// Проверяем, не добавлен ли уже этот файл
const isDuplicate = selectedImages.some(
(existingFile) =>
existingFile.name === file.name && existingFile.size === file.size
);
if (!isDuplicate) {
selectedImages.push(file);
addedCount++;
}
} else {
showNotification(`Файл "${file.name}" не является изображением`, "error");
}
});
if (addedCount > 0) {
updateImagePreview();
// Показываем уведомление о добавленных файлах
if (addedCount === 1) {
console.log(`Добавлено 1 изображение`);
} else {
console.log(`Добавлено ${addedCount} изображений`);
}
}
// Очищаем input для возможности повторного выбора тех же файлов
event.target.value = "";
});
// Обработчик очистки всех изображений
clearImagesBtn.addEventListener("click", function () {
selectedImages = [];
updateImagePreview();
imageInput.value = "";
});
// Обработчик для загрузки файлов
fileInput.addEventListener("change", function (event) {
const files = Array.from(event.target.files);
let addedCount = 0;
files.forEach((file) => {
// Проверяем размер файла (максимум 50MB)
if (file.size > 50 * 1024 * 1024) {
showNotification(
`Файл "${file.name}" слишком большой. Максимальный размер: 50MB`,
"error"
);
return;
}
// Проверяем, не добавлен ли уже файл с таким именем и размером
const isDuplicate = selectedFiles.some(
(existingFile) =>
existingFile.name === file.name && existingFile.size === file.size
);
if (!isDuplicate) {
selectedFiles.push(file);
addedCount++;
}
});
if (addedCount > 0) {
updateFilePreview();
showNotification(`Добавлено файлов: ${addedCount}`, "success");
}
fileInput.value = "";
});
// Обработчик очистки всех файлов
clearFilesBtn.addEventListener("click", function () {
selectedFiles = [];
updateFilePreview();
fileInput.value = "";
});
// Обработчики модального окна
modalClose.addEventListener("click", function () {
imageModal.style.display = "none";
});
imageModal.addEventListener("click", function (event) {
if (event.target === imageModal) {
imageModal.style.display = "none";
}
});
// Закрытие модального окна по Escape
document.addEventListener("keydown", function (event) {
if (event.key === "Escape" && imageModal.style.display === "block") {
imageModal.style.display = "none";
}
});
// Функция для обновления превью изображений
function updateImagePreview() {
if (selectedImages.length === 0) {
imagePreviewContainer.style.display = "none";
return;
}
imagePreviewContainer.style.display = "block";
imagePreviewList.innerHTML = "";
selectedImages.forEach((file, index) => {
const reader = new FileReader();
reader.onload = function (e) {
const previewItem = document.createElement("div");
previewItem.className = "image-preview-item";
// Форматируем размер файла
const fileSize = (file.size / 1024 / 1024).toFixed(2);
const fileName =
file.name.length > 20 ? file.name.substring(0, 20) + "..." : file.name;
previewItem.innerHTML = `
${fileName} ${fileSize} MB
`;
imagePreviewList.appendChild(previewItem);
// Обработчик удаления изображения
const removeBtn = previewItem.querySelector(".remove-image-btn");
removeBtn.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
selectedImages.splice(index, 1);
updateImagePreview();
});
// Дополнительный обработчик для touch событий
removeBtn.addEventListener("touchend", function (event) {
event.preventDefault();
event.stopPropagation();
selectedImages.splice(index, 1);
updateImagePreview();
});
};
reader.onerror = function () {
console.error("Ошибка чтения файла:", file.name);
showNotification(`Ошибка чтения файла: ${file.name}`, "error");
};
reader.readAsDataURL(file);
});
}
// Функция для обновления превью файлов
function updateFilePreview() {
if (selectedFiles.length === 0) {
filePreviewContainer.style.display = "none";
return;
}
filePreviewContainer.style.display = "block";
filePreviewList.innerHTML = "";
selectedFiles.forEach((file, index) => {
const previewItem = document.createElement("div");
previewItem.className = "file-preview-item";
// Форматируем размер файла
const fileSize = (file.size / 1024 / 1024).toFixed(2);
const fileName =
file.name.length > 30 ? file.name.substring(0, 30) + "..." : file.name;
// Определяем иконку по расширению файла
const ext = file.name.split(".").pop().toLowerCase();
let icon = "mdi:file";
if (ext === "pdf") icon = "mdi:file-pdf";
else if (["doc", "docx"].includes(ext)) icon = "mdi:file-word";
else if (["xls", "xlsx"].includes(ext)) icon = "mdi:file-excel";
else if (ext === "txt") icon = "mdi:file-document";
else if (["zip", "rar", "7z"].includes(ext)) icon = "mdi:folder-zip";
previewItem.innerHTML = `
${fileName}
${fileSize} MB
`;
filePreviewList.appendChild(previewItem);
// Обработчик удаления файла
const removeBtn = previewItem.querySelector(".remove-file-btn");
removeBtn.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
selectedFiles.splice(index, 1);
updateFilePreview();
});
removeBtn.addEventListener("touchend", function (event) {
event.preventDefault();
event.stopPropagation();
selectedFiles.splice(index, 1);
updateFilePreview();
});
});
}
// Функция для отображения изображения в модальном окне
function showImageModal(imageSrc) {
console.log("showImageModal called with:", imageSrc);
try {
modalImage.src = imageSrc;
imageModal.style.display = "block";
console.log("Modal opened successfully");
} catch (error) {
console.error("Error in showImageModal:", error);
}
}
// Функция для загрузки изображений на сервер
async function uploadImages(noteId) {
if (selectedImages.length === 0) {
return [];
}
const formData = new FormData();
selectedImages.forEach((file) => {
formData.append("images", file);
});
try {
// Показываем индикатор загрузки для мобильных устройств
const isMobile =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
) ||
(navigator.maxTouchPoints && navigator.maxTouchPoints > 2) ||
window.matchMedia("(max-width: 768px)").matches;
if (isMobile) {
// Создаем простое уведомление о загрузке
const loadingDiv = document.createElement("div");
loadingDiv.id = "mobile-upload-loading";
loadingDiv.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 20px;
border-radius: 10px;
z-index: 10000;
font-size: 16px;
text-align: center;
`;
loadingDiv.innerHTML = `
📤 Загрузка изображений...
${selectedImages.length} файл(ов)
`;
document.body.appendChild(loadingDiv);
}
const response = await fetch(`/api/notes/${noteId}/images`, {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error("Ошибка загрузки изображений");
}
const result = await response.json();
// Удаляем индикатор загрузки
const loadingDiv = document.getElementById("mobile-upload-loading");
if (loadingDiv) {
loadingDiv.remove();
}
return result.images || [];
} catch (error) {
console.error("Ошибка загрузки изображений:", error);
// Удаляем индикатор загрузки в случае ошибки
const loadingDiv = document.getElementById("mobile-upload-loading");
if (loadingDiv) {
loadingDiv.remove();
}
// Показываем ошибку пользователю
showNotification(`Ошибка загрузки изображений: ${error.message}`, "error");
return [];
}
}
// Функция для получения изображений заметки
async function getNoteImages(noteId) {
try {
const response = await fetch(`/api/notes/${noteId}/images`);
if (!response.ok) {
throw new Error("Ошибка получения изображений");
}
return await response.json();
} catch (error) {
console.error("Ошибка получения изображений:", error);
return [];
}
}
// Функция для загрузки файлов на сервер
async function uploadFiles(noteId) {
if (selectedFiles.length === 0) {
return [];
}
const formData = new FormData();
selectedFiles.forEach((file) => {
formData.append("files", file);
});
try {
const isMobile =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
) ||
(navigator.maxTouchPoints && navigator.maxTouchPoints > 2) ||
window.matchMedia("(max-width: 768px)").matches;
if (isMobile) {
const loadingDiv = document.createElement("div");
loadingDiv.id = "mobile-file-upload-loading";
loadingDiv.style.cssText = `
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.8); color: white; padding: 20px;
border-radius: 10px; z-index: 10000; font-size: 16px; text-align: center;
`;
loadingDiv.innerHTML = `
📎 Загрузка файлов...
${selectedFiles.length} файл(ов)
`;
document.body.appendChild(loadingDiv);
}
const response = await fetch(`/api/notes/${noteId}/files`, {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error("Ошибка загрузки файлов");
}
const result = await response.json();
const loadingDiv = document.getElementById("mobile-file-upload-loading");
if (loadingDiv) {
loadingDiv.remove();
}
return result.files || [];
} catch (error) {
console.error("Ошибка загрузки файлов:", error);
const loadingDiv = document.getElementById("mobile-file-upload-loading");
if (loadingDiv) {
loadingDiv.remove();
}
showNotification(`Ошибка загрузки файлов: ${error.message}`, "error");
return [];
}
}
// Функция для удаления изображения заметки
async function deleteNoteImage(noteId, imageId) {
try {
const response = await fetch(`/api/notes/${noteId}/images/${imageId}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error("Ошибка удаления изображения");
}
return true;
} catch (error) {
console.error("Ошибка удаления изображения:", error);
return false;
}
}
// Функция для удаления файла заметки
async function deleteNoteFile(noteId, fileId) {
try {
const response = await fetch(`/api/notes/${noteId}/files/${fileId}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error("Ошибка удаления файла");
}
return true;
} catch (error) {
console.error("Ошибка удаления файла:", error);
return false;
}
}
// Функция для загрузки заметок с сервера
async function loadNotes(forceReload = false) {
const now = Date.now();
const CACHE_DURATION = 30000; // 30 секунд кэширования
// Используем кэш, если он не устарел и не требуется принудительная перезагрузка
if (!forceReload && notesCache && now - lastLoadTime < CACHE_DURATION) {
allNotes = notesCache;
await renderNotes(notesCache);
renderCalendar();
renderTags();
renderCalendarMobile();
renderTagsMobile();
return;
}
// Показываем индикатор загрузки
showLoadingIndicator();
try {
const response = await fetch("/api/notes");
if (!response.ok) {
throw new Error("Ошибка загрузки заметок");
}
const notes = await response.json();
allNotes = notes; // Сохраняем все заметки в глобальную переменную
notesCache = notes; // Сохраняем в кэш
lastLoadTime = now;
await renderNotes(notes);
renderCalendar(); // Обновляем календарь после загрузки заметок
renderTags(); // Обновляем теги после загрузки заметок
renderCalendarMobile(); // Обновляем мобильный календарь после загрузки заметок
renderTagsMobile(); // Обновляем мобильные теги после загрузки заметок
} catch (error) {
console.error("Ошибка:", error);
notesList.innerHTML = "
Ошибка загрузки заметок
";
} finally {
// Скрываем индикатор загрузки
hideLoadingIndicator();
}
}
// Функция для показа индикатора загрузки
function showLoadingIndicator() {
if (!document.getElementById("loading-indicator")) {
const loadingDiv = document.createElement("div");
loadingDiv.id = "loading-indicator";
loadingDiv.innerHTML = `
Загрузка заметок...
`;
document.body.appendChild(loadingDiv);
}
}
// Функция для скрытия индикатора загрузки
function hideLoadingIndicator() {
const loadingIndicator = document.getElementById("loading-indicator");
if (loadingIndicator) {
loadingIndicator.remove();
}
}
// Функция для поиска заметок
async function searchNotes(query) {
if (!query || query.trim() === "") {
searchQuery = "";
searchResults = [];
await renderNotes(allNotes);
return;
}
try {
const params = new URLSearchParams();
params.append("q", query.trim());
// Добавляем фильтры, если они активны
if (selectedTagFilter) {
params.append("tag", selectedTagFilter);
}
if (selectedDateFilter) {
params.append("date", selectedDateFilter);
}
const response = await fetch(`/api/notes/search?${params}`);
if (!response.ok) {
throw new Error("Ошибка поиска заметок");
}
searchResults = await response.json();
searchQuery = query.trim();
await renderNotes(searchResults);
} catch (error) {
console.error("Ошибка поиска:", error);
searchResults = [];
await renderNotes(allNotes);
}
}
// Функция для подсветки найденного текста
function highlightSearchText(content, query) {
if (!query || query.trim() === "") {
return content;
}
const regex = new RegExp(
`(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`,
"gi"
);
return content.replace(regex, '$1');
}
// Настройка marked.js для поддержки чекбоксов и strikethrough
const renderer = new marked.Renderer();
// Функция для определения внешних ссылок
function isExternalLink(href) {
try {
const url = new URL(href);
return url.origin !== window.location.origin;
} catch (e) {
// Если URL невалидный, считаем его внутренним
return false;
}
}
// Переопределяем рендеринг ссылок для открытия внешних ссылок в браузере
const originalLink = renderer.link.bind(renderer);
renderer.link = function (href, title, text) {
const isExternal = isExternalLink(href);
if (isExternal) {
// Внешние ссылки открываем в браузере
return `${text}`;
} else {
// Внутренние ссылки обрабатываем как обычно
return originalLink(href, title, text);
}
};
// Переопределяем рендеринг списков, чтобы чекбоксы были кликабельными (без disabled)
const originalListItem = renderer.listitem.bind(renderer);
renderer.listitem = function (text, task, checked) {
if (task) {
// Удаляем disabled чекбокс из текста, если он есть
let cleanText = text.replace(/]*disabled[^>]*>/gi, "").trim();
// Создаем чекбокс БЕЗ disabled атрибута
return `
${cleanText}
\n`;
}
return originalListItem(text, task, checked);
};
// Кастомное расширение для скрытого текста (спойлеров)
const spoilerExtension = {
name: "spoiler",
level: "inline",
start(src) {
return src.match(/\|\|/) ? src.indexOf("||") : -1;
},
tokenizer(src, tokens) {
const rule = /^\|\|(.*?)\|\|/;
const match = rule.exec(src);
if (match) {
return {
type: "spoiler",
raw: match[0],
text: match[1].trim(),
};
}
},
renderer(token) {
return `${token.text}`;
},
};
// Регистрируем расширение через marked.use()
marked.use({ extensions: [spoilerExtension] });
marked.setOptions({
gfm: true, // GitHub Flavored Markdown (включает strikethrough)
breaks: true,
renderer: renderer,
html: true, // Разрешить HTML теги
});
// Функция для отображения заметок
async function renderNotes(notes) {
notesList.innerHTML = "";
// Фильтруем заметки по дате и тегам
let notesToDisplay = notes;
if (selectedDateFilter) {
notesToDisplay = notesToDisplay.filter((note) => {
if (note.created_at) {
return formatDateFromTimestamp(note.created_at) === selectedDateFilter;
}
return false;
});
}
if (selectedTagFilter) {
notesToDisplay = notesToDisplay.filter((note) => {
const tags = extractTags(note.content);
return tags.includes(selectedTagFilter);
});
}
// Если нет заметок для отображения
if (notesToDisplay.length === 0) {
let message = "Заметок пока нет";
if (selectedDateFilter && selectedTagFilter) {
message = `Нет заметок за ${selectedDateFilter} с тегом #${selectedTagFilter}`;
} else if (selectedDateFilter) {
message = `Нет заметок за выбранную дату (${selectedDateFilter})`;
} else if (selectedTagFilter) {
message = `Нет заметок с тегом #${selectedTagFilter}`;
}
notesList.innerHTML = `
${message}
`;
return;
}
// Итерируемся по заметкам в обычном порядке, чтобы новые были сверху
for (const note of notesToDisplay) {
let contentToProcess = note.content;
// Сначала подсвечиваем найденный текст в исходном markdown
if (searchQuery) {
contentToProcess = highlightSearchText(contentToProcess, searchQuery);
}
// Затем преобразуем теги в кликабельные элементы
const contentWithClickableTags = makeTagsClickable(contentToProcess);
const parsedContent = marked.parse(contentWithClickableTags);
// Используем изображения, которые уже пришли с заметкой
const noteImages = Array.isArray(note.images) ? note.images : [];
let imagesHtml = "";
if (noteImages.length > 0) {
imagesHtml = '
";
}
// Используем файлы, которые уже пришли с заметкой
const noteFiles = Array.isArray(note.files) ? note.files : [];
let filesHtml = "";
if (noteFiles.length > 0) {
filesHtml = '
';
noteFiles.forEach((file) => {
const ext = file.original_name.split(".").pop().toLowerCase();
let icon = "mdi:file";
if (ext === "pdf") icon = "mdi:file-pdf";
else if (["doc", "docx"].includes(ext)) icon = "mdi:file-word";
else if (["xls", "xlsx"].includes(ext)) icon = "mdi:file-excel";
else if (ext === "txt") icon = "mdi:file-document";
else if (["zip", "rar", "7z"].includes(ext)) icon = "mdi:folder-zip";
const fileSize = (file.file_size / 1024 / 1024).toFixed(2);
filesHtml += `