await dv.view("scripts/locations"); const path = require("path"); // В начале файла добавить: window.lastGeneratedMermaidCode = ""; // Глобальная переменная для хранения последнего сгенерированного кода // Функция, переводящая дату "ГГГГ.ММ.ДД" (с 30 днями в месяце) в число. function customDateToNumber(str) { let [y, m, d] = str.split("/"); y = parseInt(y, 10); m = parseInt(m, 10); d = parseInt(d, 10); return (y * 12 + (m - 1)) * 30 + (d - 1); } function charactersMatches(dv, charactersList, pCharacters) { if (pCharacters !== null) { if (!Array.isArray(pCharacters)) { pCharacters = [pCharacters]; } return pCharacters.some((pCharacter) => { if (typeof pCharacter === "object" && pCharacter.hasOwnProperty("path")) { const fileNameWithoutExt = path.basename( pCharacter.path, path.extname(pCharacter.path) ); return charactersList.includes(fileNameWithoutExt); } else { return charactersList.includes(pCharacter); } }); } else { return false; } } function pageWithCorrectFields(dv, p, locations, characters) { if (!dv.locations.regionMatches(dv, locations, p.regions)) return false; if ( !characters || !characters.length || charactersMatches(dv, characters, p.participants) ) return true; return false; } // Функция проверки, попадает ли дата в заданный диапазон. function isInRange(p, startStr, endStr) { const dateStr = p.date; if (!dateStr) return false; if (p.file.folder === "Templates") return false; if (typeof dateStr !== "string") return false; let dateNum = customDateToNumber(dateStr); let startNum = customDateToNumber(startStr); let endNum = customDateToNumber(endStr); return dateNum >= startNum && dateNum <= endNum; } /** * Позволяет «тащить» (drag) содержимое контейнера .mermaid-timeline * при зажатой клавише Ctrl + ЛКМ. * Реализовано через прокрутку (scroll) контейнера. */ function enableDiagramDragging(dv) { const container = dv.container.querySelector(".mermaid-timeline"); if (!container) return; // Настраиваем стили контейнера: container.style.position = "relative"; container.style.width = "100%"; // Можно зафиксировать ширину, если нужно container.style.height = "660px"; // или любую другую высоту container.style.overflow = "auto"; // Показываем полосы прокрутки container.style.border = "1px solid #ccc"; // Для наглядности container.style.cursor = "grab"; // Курсов при наведении // container.style.backgroundColor = "green"; // Курсов при наведении let isDragging = false; let startX, startY; // Координаты точки, где нажали мышь let scrollLeft, scrollTop; // Текущие значения прокрутки в момент начала «захвата» // Когда нажимаем мышь + Ctrl на контейнере container.addEventListener("mousedown", (e) => { // Проверяем, что нажата ЛКМ (button === 0) if (e.button === 2) { isDragging = true; container.style.cursor = "grabbing"; // Запоминаем начальные координаты // (pageX/Y - абсолютные координаты курсора на странице) // offsetLeft / offsetTop нужны, если контейнер имеет отступы startX = e.pageX - container.offsetLeft; startY = e.pageY - container.offsetTop; // Запоминаем текущую прокрутку scrollLeft = container.scrollLeft; scrollTop = container.scrollTop; e.preventDefault(); // Чтобы не выделялся текст } }); // Если мышь уходит за пределы контейнера — перестаём «тянуть» container.addEventListener("mouseleave", () => { isDragging = false; container.style.cursor = "grab"; }); // Когда отпускаем кнопку мыши — перестаём «тянуть» container.addEventListener("mouseup", () => { isDragging = false; container.style.cursor = "grab"; }); // При движении мыши — меняем scroll контейнера container.addEventListener("mousemove", (e) => { if (!isDragging) return; e.preventDefault(); // Текущее положение мыши относительно контейнера const x = e.pageX - container.offsetLeft; const y = e.pageY - container.offsetTop; // Смещение, на которое «увели» мышь const walkX = x - startX; const walkY = y - startY; // Двигаем прокрутку «вверх/вниз» и «влево/вправо» (с учётом смещения) container.scrollLeft = scrollLeft - walkX; container.scrollTop = scrollTop - walkY; }); } const zoomStep = 0.1; // Шаг изменения масштаба // 0.1 // Добавляем обработчик для масштабирования при зажатом Ctrl + колесико мыши function enableZoomControl(timelineElement) { if (timelineElement) { timelineElement.addEventListener("wheel", (event) => { if (event.ctrlKey) { event.preventDefault(); // Предотвращаем прокрутку страницы let scale = timelineElement.dataset.scale ? parseFloat(timelineElement.dataset.scale) : 1; if (event.deltaY < 0) { // Увеличение масштаба scale += zoomStep; } else { // Уменьшение масштаба scale = Math.max(scale - zoomStep, 0.1); // Минимальный масштаб: 0.1 } timelineElement.dataset.scale = scale; // Сохраняем текущий масштаб timelineElement.style.transform = `scale(${scale})`; // Применяем масштабирование через CSS console.log(`Масштаб изменен: ${scale}`); } }); } else { console.error('Элемент с ID "timeline" не найден.'); } } // Добавляем обработчик контекстного меню для нодов function setupContextMenu(dv) { // Если ссылки находятся в определенном контейнере const nodes = dv.container.querySelectorAll( ".mermaid-timeline a.internal-link" ); nodes.forEach((node) => { node.addEventListener("contextmenu", handleContextMenu); }); } async function generateUniqueFileName(basePath) { let counter = 0; let filePath = `${basePath}.md`; // Проверяем существование файла while (await app.vault.adapter.exists(filePath)) { counter++; filePath = `${basePath} - ${counter}.md`; } return filePath; } const handleContextMenu = async (e) => { e.preventDefault(); const node = e.currentTarget; const nodeName = node.getAttribute("href"); // Проверяем, что нода находится внутри mermaid диаграммы const isMermaidNode = node.closest(".mermaid") !== null; if (!isMermaidNode) return; const originalPage = dv .pages("#event") .where((p) => p.file.name === nodeName) .first(); if (!originalPage) return; // Создаем одноразовый обработчик события const menuHandler = (menu, file) => { // Проверяем, что это меню для нашего файла if (!file || file.path !== originalPage.file.path) return; menu.addSeparator(); menu.addItem((item) => { item .setTitle("Create New Event") .onClick(() => createNewEvent(originalPage, "new")); }); menu.addItem((item) => { item .setTitle("Create Previous Event") .onClick(() => createNewEvent(originalPage, "previous")); }); menu.addItem((item) => { item .setTitle("Create Next Event") .onClick(() => createNewEvent(originalPage, "next")); }); // Удаляем обработчик после использования app.workspace.off("file-menu", menuHandler); }; // Регистрируем временный обработчик app.workspace.on("file-menu", menuHandler); // Вызываем меню // TODO: провоцирует ошибки, в т.ч. `app.js:1 Uncaught TypeError: e.addItem is not a function` // и app.js:1 Uncaught TypeError: Cannot read properties of undefined (reading 'path'), но справиться с этим не удалось app.workspace.trigger("file-menu", { file: originalPage.file, node: node, x: e.pageX, y: e.pageY, }); }; async function createNewEvent(originalPage, type) { // Получаем путь к папке оригинального файла const folderPath = originalPage.file.folder; const date = originalPage.date; const regions = originalPage.regions; const [year, month, day] = date.split("/"); const formattedDate = `${year}.${month.padStart(2, "0")}.${day.padStart( 2, "0" )}`; // Создаем имя нового файла const newFileName = `${formattedDate} Новое событие`; const fullPath = folderPath ? `${folderPath}/${newFileName}` : newFileName; try { // Получаем содержимое шаблона const templateContent = await app.vault.adapter.read( "Templates/Preparation/Fields for Event.md" ); // Генерируем уникальное имя файла const uniqueFilePath = await generateUniqueFileName(fullPath); // Создаем новый файл let file = await app.vault.create(uniqueFilePath, templateContent); await app.fileManager.processFrontMatter(file, (fm) => { fm["date"] = date; if (!fm["regions"]) { fm["regions"] = []; } let filePath = ""; if (Array.isArray(regions)) { regions.forEach((region) => { if (typeof region === "object") { filePath = region.path; } else { filePath = region; } const fileNameWithoutExt = path.basename( filePath, path.extname(filePath) ); fm["regions"].push(`[[${fileNameWithoutExt}]]`); }); } else { if (typeof regions === "object") { filePath = regions.path; } else { filePath = regions; } const fileNameWithoutExt = path.basename( filePath, path.extname(filePath) ); fm["regions"].push(`[[${fileNameWithoutExt}]]`); } if (!fm["nextEvents"]) { fm["nextEvents"] = []; } if (type === "previous") { fm["nextEvents"].push(`[[${originalPage.file.name}]]`); } }); // Получаем только имя файла без пути и расширения const uniqueFileName = uniqueFilePath.split("/").pop().replace(".md", ""); // Показываем уведомление об успехе new Notice(`Создано новое событие: ${uniqueFileName}`); // Если это следующее событие, обновляем nextEvents в текущем файле if (type === "next") { const currentFile = app.vault.getAbstractFileByPath( `${originalPage.file.path}` ); if (currentFile) { await app.fileManager.processFrontMatter(currentFile, (fm) => { if (!fm["nextEvents"]) { fm["nextEvents"] = []; } fm["nextEvents"].push(`[[${file.basename}]]`); }); } } // Открываем новый файл в новой вкладке const leaf = app.workspace.getLeaf("tab"); // создаем новую вкладку await leaf.openFile(file, { active: false }); // открываем файл, не переключаясь на него // Обновлять таймлайн не надо, в случае создания нового файла самостоятельно заметка обновится // updateTimeline(); } catch (error) { console.error("Ошибка при создании файла:", error); new Notice("Ошибка при создании события"); } } const monthsHarpotos = { 1: "Хаммер", 2: "Альтуриак", 3: "Чес", 4: "Тарсах", 5: "Миртул", 6: "Киторн", 7: "Флеймрул", 8: "Элесиас", 9: "Элейнт", 10: "Марпенот", 11: "Уктар", 12: "Найтол", }; function showTimeline( dv, locations, characters, currentDate, startDate, endDate, pcsParticipationOnly, includeRegionsParticipants, fullWidth = false ) { // Выбираем страницы с полем date, попадающим в диапазон: let pages = dv .pages("#event") .filter((p) => pageWithCorrectFields(dv, p, locations, characters)) .where( (p) => isInRange(p, startDate, endDate) && (!pcsParticipationOnly || p.pcsParticipation) ); if (pages.length == 0) { dv.el("div", "Not found", { cls: "mermaid-timeline" }); window.lastGeneratedMermaidCode = ""; return; } // Группируем по "год.месяц", используя существующую функцию // Не оптимально, надо потом упростить, попробовать уйти от displayKey ниже let groups = pages .groupBy((p) => { let dateNum = customDateToNumber(p.date); let [y, m, d] = p.date.split("/"); return { numericKey: Math.floor(dateNum / 30), // убираем дни, оставляем только год и месяц displayKey: `${y}.${m}`, // для отображения }; }) .sort((g) => g.key.numericKey); // Начинаем собирать строку для mermaid-диаграммы: let result = "```mermaid\n"; result += `%% ниже представлены события в текущей НРИ кампании, текущая дата: ${currentDate}.\n\n`; // для foreignObject и foreignObject div свойства в т.ч., чтобы были переносы для нодов с длинными названиями, иначе одной строкой выводит result += `%%{init: {'themeCSS': '.nodes rect { fill: none; stroke: none; }; ` + `.cluster rect { fill: var(--background-primary-alt); stroke: var(--background-modifier-border); }` + `.node:not(.pointNode) foreignObject { width: 100px !important; height: auto !important; overflow: visible !important; }; ` + `.node:not(.pointNode) foreignObject div { display: block; min-width: fit-content; width: auto !important; height: auto; white-space: normal !important; padding: 4px 6px; background-color: var(--background-secondary-alt); border: 1px solid var(--background-modifier-border); }` + `.node.pcs-participation:not(.pointNode) foreignObject div { background-color: #d7fafa; border: 1px solid var(--background-modifier-border); }` + `.node.future-events:not(.pointNode) foreignObject div { background-color:rgb(216, 244, 177); border: 1px solid var(--background-modifier-border); }` + `.context-menu div:hover { background-color: var(--background-modifier-hover); }` + // стили для контекстного меню `.context-menu div { transition: background-color 0.1s ease; }` + `'}}%%\n\n`; result += "graph LR\n\n"; // Соберём связи и классы в массивы: let connections = []; let pathToNodeName = {}; let subgraphLinks = []; let nodeClasses = []; // Для определения классов узлов let customStyleNodeClasses = []; // Для определения классов узлов // 1. Генерируем сабграфы let idx = 1; for (let group of groups) { let monthNumber = parseInt(group.key.displayKey.split(".")[1], 10); let monthName = monthsHarpotos[monthNumber]; result += ` subgraph Layer${idx} [${group.key.displayKey} ${monthName}]\n`; let tNodeName = `T${idx}`; result += ` ${tNodeName}(( ))\n`; // Добавляем узел T в начало сабграфа nodeClasses.push(` class ${tNodeName} pointNode;`); for (let item of group.rows) { // Уберём пробелы из названия файла, чтобы Mermaid корректно рендерил: let nodeName = "event_" + item.file.name.replace(/[\s.,()[\]{}]+/g, "_"); // чтобы получить корректное название нода let nodeLabel = item.file.name; // Отображаемое имя (с пробелами) if (item.file.path) pathToNodeName[item.file.path] = nodeName; // Добавляем ноду в сабграф с отображаемым именем if (item.summary) result += ` %% summary: ${item.summary}\n`; if (includeRegionsParticipants) { if (item.regions) result += ` %% regions: ${item.regions}\n`; if (item.participants) result += ` %% participants: ${item.participants}\n`; } result += ` ${nodeName}[${nodeLabel}]\n`; // Добавляем класс для ноды nodeClasses.push(` class ${nodeName} internal-link;`); if (customDateToNumber(item.date) > customDateToNumber(currentDate)) customStyleNodeClasses.push(` class ${nodeName} future-events;`); else if (item.pcsParticipation) customStyleNodeClasses.push(` class ${nodeName} pcs-participation;`); if (item.nextEvents) { let nextEvents = Array.isArray(item.nextEvents) ? item.nextEvents : [item.nextEvents]; for (let nextEvent of nextEvents) { if (nextEvent && nextEvent.path) { connections.push([nodeName, nextEvent.path]); } } } } result += " end\n\n"; if (idx > 1) { // Добавляем связь между сабграфами: T --> T subgraphLinks.push(` T${idx - 1} --> T${idx}:::customLink;`); // A-->|Текст связи|B:::customLink; } idx++; } // 2. Добавляем блок со связями между сабграфами if (subgraphLinks.length > 0) { result += " %% Связи между сабграфами\n"; result += subgraphLinks.join("\n") + "\n\n"; } // 3. Вставляем блок со связями между нодами, если они есть if (connections.length > 0) { result += " %% Связи\n"; for (let connection of connections) { let fromNodeName = connection[0]; let nextEventPath = connection[1]; let nextEventNodeName = pathToNodeName[nextEventPath]; if (nextEventNodeName) result += ` ${fromNodeName} --> ${nextEventNodeName}\n`; } } result += " %% Классы узлов и связей\n"; result += " classDef pointNode fill:lightgray,stroke:none,opacity:1,radius:2px;\n"; result += " %% Объявляем класс для ссылок\n"; result += " classDef customLink stroke:lightgray,stroke-width:0px,stroke-dasharray:5 5;\n"; result += " classDef pcs-participation color:inherit;\n"; // зададим через css выше, т.к. здесь свойства для g-контейнера div'а result += " classDef future-events color:inherit;\n"; // зададим через css выше, т.к. здесь свойства для g-контейнера div'а // 4. Вставляем блок для классов узлов if (nodeClasses.length > 0) { result += " %% Классы узлов\n"; result += nodeClasses.join("\n") + "\n"; result += customStyleNodeClasses.join("\n") + "\n"; } // Закрываем кодовый блок с мермейдом result += "```"; // Создаём div с конкретным классом let mermaidTimelineDiv = dv.el("div", result, { cls: "mermaid-timeline" }); // Сохраняем сгенерированный код window.lastGeneratedMermaidCode = result; // Активируем drag-перемещение enableDiagramDragging(dv); // Вызываем функцию после рендеринга диаграммы setTimeout(() => { setupContextMenu(dv); enableZoomControl(mermaidTimelineDiv.querySelector(".mermaid")); }, 1000); // Даем время на рендеринг Mermaid, передаём dv через лямбду } async function run_show_timeline(...args) { if (!args[0] || !args[0].arg5) return; // не имеет свойства length let dv = args[0].arg1; let locations = args[0].arg2; // Предполагается, что это массив целевых локаций let characters = args[0].arg3; let defaultCurrentDate = args[0].arg4; let startDate = args[0].arg5; let endDate = args[0].arg6; let pcsParticipationOnly = args[0].arg7; let includeRegionsParticipants = args[0].arg8; showTimeline( dv, locations, characters, defaultCurrentDate, startDate, endDate, pcsParticipationOnly, includeRegionsParticipants ); } await run_show_timeline(input); dv.timeline = { showTimeline, };