578 lines
22 KiB
JavaScript
578 lines
22 KiB
JavaScript
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<i> в начало сабграфа
|
||
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<i-1> --> T<i>
|
||
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,
|
||
};
|