universal-dashboard/script.js

2918 lines
102 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ecosystem-panel / script.js
//
// ОГЛАВЛЕНИЕ — ищите секции по строке «// =====» (Ctrl+F):
// Persistent storage keys
// Panel configuration (settings.json)
// Panel pattern catalog and defaults
// Default UI preferences
// Runtime state and DOM references
// Initial render
// Event bindings
// Panel config loading
// Data helpers
// Catalog rendering
// Search matching and highlighting
// Sidebar tree rendering
// Focusing from tree to content
// Collapse state handling
// Card pointer interaction
// Preferences application
// Generic helpers
// Panel pattern preferences (normalize, apply, custom, UI)
// localStorage helpers (read/save, import/export/reset)
//
// Связанные файлы: index.html (разметка ⚙), styles.css (узоры, темы, слои).
// Документация: README.md → «Узоры панелей», «Разделение слоёв», «Экспорт настроек».
// ============================================================================
// Persistent storage keys
// ============================================================================
// STORAGE_KEY хранит пользовательские настройки интерфейса:
// тему, акцент, режим отображения, ширину сайдбара и toggle-настройки.
const STORAGE_KEY = "ecosystem-panel-preferences";
// COLLAPSE_STORAGE_KEY хранит ручные состояния раскрытия details:
// отдельно для дерева навигации и отдельно для контентных секций.
const COLLAPSE_STORAGE_KEY = "ecosystem-panel-collapse-state";
// ============================================================================
// Panel configuration (settings.json)
// ============================================================================
// settings.json задает источник данных панели:
// - dataUrl: URL или путь к JSON (fetch), например "./panel-data.json"
// - data: inline-объект с той же структурой, если dataUrl пустой
const panelScript =
document.currentScript ?? document.querySelector("script[data-ecosystem-panel]");
const PANEL_ASSET_BASE = panelScript?.dataset.assetBase ?? "";
const SETTINGS_PATH = panelScript?.dataset.settingsPath ?? "./settings.json";
function resolveAssetPath(path) {
if (!path || /^(?:[a-z]+:|\/\/|\/)/i.test(path)) {
return path;
}
return `${PANEL_ASSET_BASE}${path}`;
}
let ecosystemSections = [];
let panelTitle = "Панель экосистемы";
const defaultServiceMeta = {
analytics: { label: "Аналитика", icon: "assets/analytics.svg" },
api: { label: "API", icon: "assets/api.svg" },
calendar: { label: "Календарь", icon: "assets/calendar.svg" },
ceph: { label: "Ceph", icon: "assets/ceph.svg" },
chat: { label: "Беседа", icon: "assets/chat.svg" },
cicd: { label: "CI/CD", icon: "assets/cicd.svg" },
cloudbeaver: { label: "CloudBeaver", icon: "assets/cloudbeaver.svg" },
config: { label: "Config", icon: "assets/config.svg" },
drive: { label: "Диск", icon: "assets/drive.svg" },
forum: { label: "Форум", icon: "assets/forum.svg" },
gitea: { label: "Gitea", icon: "assets/gitea.svg" },
grafana: { label: "Grafana", icon: "assets/grafana.svg" },
ipmi: { label: "IPMI", icon: "assets/server.svg" },
kubernetes: { label: "Kubernetes", icon: "assets/kubernetes.svg" },
ldap: { label: "LDAP", icon: "assets/ldap.svg" },
messages: { label: "Messages", icon: "assets/messages.svg" },
opensearch: { label: "OpenSearch", icon: "assets/opensearch.svg" },
postgresql: { label: "PostgreSQL", icon: "assets/postgresql.svg" },
prometheus: { label: "Prometheus", icon: "assets/prometheus.svg" },
rabbitmq: { label: "RabbitMQ", icon: "assets/rabbitmq.svg" },
redmine: { label: "Redmine", icon: "assets/redmine.svg" },
rules: { label: "Rules", icon: "assets/rules.svg" },
social: { label: "Соцсеть", icon: "assets/social.svg" },
vault: { label: "Vault", icon: "assets/vault.svg" },
video: { label: "Видео", icon: "assets/video.svg" },
vpn: { label: "VPN", icon: "assets/vpn.svg" },
wiki: { label: "Wiki", icon: "assets/wiki.svg" },
xpath: { label: "xPath", icon: "assets/xpath.svg" },
xmpp: { label: "XMPP", icon: "assets/xmpp.svg" },
};
let serviceMeta = { ...defaultServiceMeta };
// ============================================================================
// Panel pattern catalog and defaults
// ============================================================================
// Узоры накладываются на четыре типа блоков интерфейса (см. panelPatternTargetIds).
//
// Как это работает:
// 1. panelPatternCatalog — пресеты для кнопок в ⚙ (генерируются в renderPatternSwatches).
// 2. preferences.panelPattern{Nav|Section|Group|Item} — выбранный id пресета или "custom".
// 3. applyPanelPatternPreferences() выставляет data-panel-pattern-* на <html> и CSS-переменные.
// 4. Встроенные пресеты: styles.css → --pattern-tile-* + html[data-panel-pattern-*="hex"] …
// 5. custom: panelPattern*CustomImage + CustomSize → inline --panel-pattern-image-{target}.
//
// См. README.md → «Узоры панелей», «Свой узор», «Разделение слоёв».
/** Допустимые id узоров (валидация в normalizePanelPatternPreferences). */
const availablePanelPatterns = [
"none",
"hex",
"hex-sm",
"dots",
"dots-sm",
"dots-lg",
"grid",
"diagonal",
"cross",
"mesh",
"tri-sm",
"tri-lg",
"diamond",
"custom",
];
/** Подписи пресетов в UI настроек. Порядок = порядок кнопок в сетке pattern-swatches. */
const panelPatternCatalog = [
{ id: "none", label: "Нет" },
{ id: "hex", label: "Соты" },
{ id: "hex-sm", label: "Соты м." },
{ id: "dots", label: "Точки" },
{ id: "dots-sm", label: "Точки м." },
{ id: "dots-lg", label: "Точки кр." },
{ id: "grid", label: "Сетка" },
{ id: "diagonal", label: "Диаг." },
{ id: "cross", label: "Клетка" },
{ id: "mesh", label: "Mesh" },
{ id: "tri-sm", label: "△ мел." },
{ id: "tri-lg", label: "△ кр." },
{ id: "diamond", label: "Ромбы" },
{ id: "custom", label: "Свой" },
];
/** Размер плитки по умолчанию для custom-узора (background-size). */
const defaultPanelPatternCustomSize = "32px 32px";
/** Цели узора: nav → .hero-content, section → .catalog-section, group → .catalog-group, item → .nav-card */
const panelPatternTargetIds = ["nav", "section", "group", "item"];
/** Слайдеры узора/градиента по умолчанию (%, 0–100). Переопределяются settings.json → defaults. */
const panelPatternTuningDefaults = {
opacity: 58,
height: 38,
fade: 18,
gradientOpacity: 62,
gradientHeight: 34,
gradientFade: 16,
};
/** Стартовый пресет узора для каждой цели, если в localStorage / settings.json нет значения. */
const panelPatternTargetMeta = {
nav: { defaultPattern: "hex" },
section: { defaultPattern: "hex" },
group: { defaultPattern: "none" },
item: { defaultPattern: "none" },
};
function capitalizePanelPatternTarget(target) {
return target.charAt(0).toUpperCase() + target.slice(1);
}
/** Собирает ключи panelPattern* для всех целей (pattern, opacity, customImage, …). */
function buildDefaultPanelPatternPreferences() {
const prefs = {};
for (const target of panelPatternTargetIds) {
const cap = capitalizePanelPatternTarget(target);
prefs[`panelPattern${cap}`] = panelPatternTargetMeta[target].defaultPattern;
prefs[`panelPattern${cap}Opacity`] = panelPatternTuningDefaults.opacity;
prefs[`panelPattern${cap}Height`] = panelPatternTuningDefaults.height;
prefs[`panelPattern${cap}Fade`] = panelPatternTuningDefaults.fade;
prefs[`panelPattern${cap}GradientOpacity`] = panelPatternTuningDefaults.gradientOpacity;
prefs[`panelPattern${cap}GradientHeight`] = panelPatternTuningDefaults.gradientHeight;
prefs[`panelPattern${cap}GradientFade`] = panelPatternTuningDefaults.gradientFade;
prefs[`panelPattern${cap}CustomImage`] = "";
prefs[`panelPattern${cap}CustomSize`] = defaultPanelPatternCustomSize;
}
return prefs;
}
// ============================================================================
// Default UI preferences
// ============================================================================
// Значения используются при первом открытии или если localStorage пуст/битый.
const defaultPreferences = {
theme: window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark",
view: "cards",
accent: "violet",
customAccentFrom: "#8b5cf6",
customAccentTo: "#22d3ee",
customAccentAngle: 135,
...buildDefaultPanelPatternPreferences(),
patternIsolation: 46,
navLayout: "sidebar",
showTree: true,
showResizeHandles: true,
showNumbers: false,
showTreeTags: true,
showTreeType: false,
treeMetaLayout: "inline",
treeColumns: 1,
autoCollapseSearch: true,
sidebarWidth: 360,
topBarHeight: 140,
shellMaxWidth: 2240,
sidebarMinWidth: 240,
sidebarMaxWidth: 720,
sidebarMaxWidthWide: 1040,
treeSidebarBaseWidth: 360,
treeSidebarColumnWidth: 96,
topBarMinHeight: 80,
topBarMaxHeight: 560,
catalogCardsColumns: 3,
catalogListColumns: 1,
catalogCompactCardsColumns: 4,
catalogCompactListColumns: 2,
searchPlaceholder: "Grafana, S3, журнал, сервер...",
settingsPanelWidth: 440,
collapseAnimation: "animated",
collapseDuration: 260,
};
const defaultPanelFeatures = {
allowNavLayoutChange: true,
allowShowTreeChange: true,
allowSearch: true,
allowViewChange: true,
allowCatalogColumnsChange: true,
allowShowNumbers: true,
allowTreeOptions: true,
allowSidebarResize: true,
allowLayoutLimitsChange: true,
allowThemeChange: true,
allowAccentChange: true,
allowPanelPatternChange: true,
};
const defaultPanelLimits = {
shellMaxWidth: { min: 1200, max: 3840 },
sidebarMinWidth: { min: 220, max: 480 },
sidebarMaxWidth: { min: 400, max: 1200 },
sidebarMaxWidthWide: { min: 560, max: 2000 },
treeSidebarBaseWidth: { min: 280, max: 720 },
treeSidebarColumnWidth: { min: 60, max: 200 },
topBarMinHeight: { min: 64, max: 240 },
topBarMaxHeight: { min: 160, max: 800 },
catalogCardsColumns: { min: 1, max: 6 },
catalogListColumns: { min: 1, max: 4 },
catalogCompactCardsColumns: { min: 2, max: 8 },
catalogCompactListColumns: { min: 1, max: 6 },
settingsPanelWidth: { min: 320, max: 720 },
};
const layoutLimitKeys = [
"shellMaxWidth",
"sidebarMinWidth",
"sidebarMaxWidth",
"sidebarMaxWidthWide",
"treeSidebarBaseWidth",
"treeSidebarColumnWidth",
"topBarMinHeight",
"topBarMaxHeight",
];
const catalogColumnKeys = [
"catalogCardsColumns",
"catalogListColumns",
"catalogCompactCardsColumns",
"catalogCompactListColumns",
];
const panelLimitKeys = [...layoutLimitKeys, ...catalogColumnKeys, "settingsPanelWidth"];
const availableNavLayouts = ["sidebar", "top"];
const availableTreeMetaLayouts = ["inline", "stacked"];
const MIN_TREE_COLUMNS = 1;
const MAX_TREE_COLUMNS = 6;
const availableViews = ["cards", "list", "compact-cards", "compact-list"];
const availableAccents = ["violet", "cyan", "lime", "amber", "custom"];
const availableCollapseAnimations = ["instant", "animated"];
const MIN_COLLAPSE_DURATION = 100;
const MAX_COLLAPSE_DURATION = 800;
const MIN_PATTERN_ISOLATION = 0;
const MAX_PATTERN_ISOLATION = 100;
const COLLAPSIBLE_DETAILS_SELECTOR =
".catalog-section, .catalog-group, .tree-section, .tree-group, .settings-section-collapsible, .pattern-target-panel";
// ============================================================================
// Runtime state and DOM references
// ============================================================================
// preferences - текущие настройки интерфейса; итоговые значения собираются в bootstrap().
let panelDefaults = { ...defaultPreferences };
let panelFeatures = { ...defaultPanelFeatures };
let panelLimits = structuredClone(defaultPanelLimits);
let preferences = { ...defaultPreferences };
// collapseState - объект вида { [collapseKey]: boolean } для details.
const collapseState = readCollapseState();
// Корневые DOM-узлы, которые нужны для рендера и событий.
const appLayout = document.querySelector(".app-layout");
const catalog = document.querySelector("[data-catalog-root]");
const navTree = document.querySelector("[data-nav-tree]");
const sidebarResizer = document.querySelector("[data-sidebar-resizer]");
const topBarResizer = document.querySelector("[data-top-bar-resizer]");
const searchField = document.querySelector("[data-search-field]");
const searchInput = document.querySelector("[data-search-input]");
const settingsToggle = document.querySelector("[data-settings-toggle]");
const settingsPanel = document.querySelector("[data-settings-panel]");
const settingsOverlay = document.querySelector("[data-settings-overlay]");
const settingsClose = document.querySelector("[data-settings-close]");
const themeButtons = document.querySelectorAll("[data-theme]");
const navLayoutButtons = document.querySelectorAll("[data-nav-layout]");
const viewButtons = document.querySelectorAll("[data-view]");
const accentButtons = document.querySelectorAll("[data-accent]");
const patternTargetsPanel = document.querySelector("[data-pattern-targets-panel]");
const accentCustomPanel = document.querySelector("[data-accent-custom-panel]");
const customAccentFromInput = document.querySelector("[data-custom-accent-from]");
const customAccentToInput = document.querySelector("[data-custom-accent-to]");
const customAccentAngleInput = document.querySelector("[data-custom-accent-angle]");
const customAccentAngleValue = document.querySelector("[data-custom-accent-angle-value]");
const customAccentPreview = document.querySelector("[data-custom-accent-preview]");
const showTreeInput = document.querySelector("[data-show-tree]");
const showResizeHandlesInput = document.querySelector("[data-show-resize-handles]");
const showNumbersInput = document.querySelector("[data-show-numbers]");
const showTreeTagsInput = document.querySelector("[data-show-tree-tags]");
const showTreeTypeInput = document.querySelector("[data-show-tree-type]");
const treeColumnsInput = document.querySelector("[data-tree-columns]");
const treeColumnsValue = document.querySelector("[data-tree-columns-value]");
const treeMetaLayoutButtons = document.querySelectorAll("[data-tree-meta-layout]");
const autoCollapseSearchInput = document.querySelector("[data-auto-collapse-search]");
const settingsPanelWidthInput = document.querySelector("[data-settings-panel-width]");
const settingsPanelWidthValue = document.querySelector("[data-settings-panel-width-value]");
const layoutLimitInputs = document.querySelectorAll("[data-layout-limit]");
const catalogColumnInputs = document.querySelectorAll("[data-catalog-columns]");
const patternIsolationInput = document.querySelector("[data-pattern-isolation]");
const patternIsolationValue = document.querySelector("[data-pattern-isolation-value]");
const collapseAnimationButtons = document.querySelectorAll("[data-collapse-animation]");
const collapseDurationInput = document.querySelector("[data-collapse-duration]");
const collapseDurationValue = document.querySelector("[data-collapse-duration-value]");
const settingsSections = document.querySelectorAll("[data-settings-section]");
const settingsResetButton = document.querySelector("[data-settings-reset]");
const settingsExportButton = document.querySelector("[data-settings-export]");
const settingsImportButton = document.querySelector("[data-settings-import]");
const settingsImportInput = document.querySelector("[data-settings-import-input]");
const emptyTemplate = document.querySelector("#empty-template");
const configErrorTemplate = document.querySelector("#config-error-template");
const pageTitleElement = document.querySelector("#page-title");
// ============================================================================
// Initial render
// ============================================================================
bootstrap();
async function bootstrap() {
try {
const settings = await fetchJson(SETTINGS_PATH);
applyOrganizationConfig(settings);
preferences = enforceFeatureLocks(normalizePreferences({ ...panelDefaults, ...readPreferences() }));
const panelData = await loadPanelDataFromSettings(settings);
ecosystemSections = panelData.sections;
serviceMeta = panelData.serviceMeta;
panelTitle = panelData.title;
pageTitleElement.textContent = panelTitle;
document.title = panelTitle;
} catch (error) {
showConfigError(error);
return;
}
setupPatternControls();
renderNavigationTree();
applyPreferences(preferences);
renderCatalog();
}
// ============================================================================
// Event bindings
// ============================================================================
// Поиск перерисовывает и дерево, и каталог:
// - дерево получает подсветку совпадений и авто-раскрытие веток;
// - каталог фильтруется и тоже подсвечивает найденный текст.
searchInput.addEventListener("input", () => {
renderNavigationTree();
renderCatalog();
});
// Клик по файлу/папке в дереве фокусирует соответствующую секцию/карточку.
// Само раскрытие details остается на нативном summary/caret.
navTree.addEventListener("click", (event) => {
const button = event.target.closest("[data-focus-target]");
if (!button) {
return;
}
event.preventDefault();
event.stopPropagation();
focusCatalogTarget(button.dataset.focusTarget);
});
// Сохраняем ручное раскрытие/сворачивание дерева и контента.
// Используем capture=true, потому что toggle не всплывает как обычный click.
navTree.addEventListener("toggle", handleDetailsToggle, true);
catalog.addEventListener("toggle", handleDetailsToggle, true);
settingsPanel?.addEventListener("toggle", handleDetailsToggle, true);
document.addEventListener("click", handleAnimatedDetailsClick, true);
// Открытие popup-настроек по шестеренке.
settingsToggle.addEventListener("click", () => {
const isOpen = settingsPanel.classList.toggle("is-open");
settingsOverlay.classList.toggle("is-open", isOpen);
settingsToggle.setAttribute("aria-expanded", String(isOpen));
});
// Закрытие popup-настроек всеми привычными способами.
settingsClose.addEventListener("click", closeSettingsPanel);
settingsOverlay.addEventListener("click", closeSettingsPanel);
settingsResetButton?.addEventListener("click", resetStoredPreferences);
settingsExportButton?.addEventListener("click", exportStoredPreferences);
settingsImportButton?.addEventListener("click", () => settingsImportInput?.click());
settingsImportInput?.addEventListener("change", async () => {
const file = settingsImportInput.files?.[0];
if (!file) {
return;
}
try {
const imported = await readImportedPreferencesFile(file);
applyStoredPreferences(imported);
} catch (error) {
window.alert(error instanceof Error ? error.message : "Не удалось импортировать настройки.");
} finally {
settingsImportInput.value = "";
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closeSettingsPanel();
}
});
document.addEventListener("click", (event) => {
if (!settingsPanel.classList.contains("is-open")) {
return;
}
if (settingsPanel.contains(event.target) || settingsToggle.contains(event.target)) {
return;
}
closeSettingsPanel();
});
themeButtons.forEach((button) => {
button.addEventListener("click", () => {
if (!panelFeatures.allowThemeChange) {
return;
}
preferences.theme = button.dataset.theme;
applyPreferences(preferences);
savePreferences(preferences);
});
});
navLayoutButtons.forEach((button) => {
button.addEventListener("click", () => {
if (!panelFeatures.allowNavLayoutChange) {
return;
}
preferences.navLayout = button.dataset.navLayout;
applyPreferences(preferences);
if (isTreeVisible(preferences)) {
renderNavigationTree();
} else {
navTree.replaceChildren();
}
savePreferences(preferences);
});
});
function setShowTree(nextValue, { persist = true } = {}) {
if (!panelFeatures.allowShowTreeChange) {
return;
}
preferences.showTree = Boolean(nextValue);
applyPreferences(preferences);
if (preferences.showTree) {
renderNavigationTree();
} else {
navTree.replaceChildren();
}
if (persist) {
savePreferences(preferences);
}
}
if (showTreeInput) {
showTreeInput.addEventListener("change", () => {
setShowTree(showTreeInput.checked);
});
}
if (showResizeHandlesInput) {
showResizeHandlesInput.addEventListener("change", () => {
if (!panelFeatures.allowSidebarResize) {
return;
}
preferences.showResizeHandles = showResizeHandlesInput.checked;
applyPreferences(preferences);
savePreferences(preferences);
});
}
// Переключение режима отображения каталога.
viewButtons.forEach((button) => {
button.addEventListener("click", () => {
if (!panelFeatures.allowViewChange) {
return;
}
preferences.view = button.dataset.view;
applyPreferences(preferences);
savePreferences(preferences);
});
});
// Выбор цветового акцента из пресетов или пользовательского градиента.
accentButtons.forEach((button) => {
button.addEventListener("click", () => {
if (!panelFeatures.allowAccentChange) {
return;
}
preferences.accent = button.dataset.accent;
applyPreferences(preferences);
savePreferences(preferences);
});
});
patternTargetsPanel?.addEventListener("click", (event) => {
const button = event.target.closest("[data-panel-pattern-target][data-panel-pattern]");
if (!button || !panelFeatures.allowPanelPatternChange) {
return;
}
const target = button.dataset.panelPatternTarget;
const pattern = button.dataset.panelPattern;
preferences[getPanelPatternPreferenceKey(target, "pattern")] = pattern;
applyPreferences(preferences);
savePreferences(preferences);
if (pattern === "custom") {
const imageInput = patternTargetsPanel?.querySelector(
`[data-pattern-custom-panel="${target}"] [data-pattern-custom-field="image"]`,
);
imageInput?.focus({ preventScroll: true });
imageInput?.closest(".pattern-custom-panel")?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
});
patternTargetsPanel?.addEventListener("input", (event) => {
const tuningInput = event.target.closest("[data-panel-pattern-tuning]");
const customFieldInput = event.target.closest("[data-pattern-custom-field]");
if (!panelFeatures.allowPanelPatternChange) {
return;
}
if (tuningInput) {
const target = tuningInput.dataset.panelPatternTuning;
const field = tuningInput.dataset.panelPatternField;
const key = getPanelPatternPreferenceKey(target, field);
const fallback = defaultPreferences[key] ?? panelPatternTuningDefaults[field] ?? 50;
preferences[key] = clampPanelPatternPercent(tuningInput.value, fallback);
applyPreferences(preferences);
savePreferences(preferences);
return;
}
if (customFieldInput) {
const target = customFieldInput.dataset.panelPatternTarget;
const field = customFieldInput.dataset.patternCustomField;
const patternKey = getPanelPatternPreferenceKey(target, "pattern");
if (field === "image") {
preferences[getPanelPatternPreferenceKey(target, "customImage")] = normalizePatternCustomImage(
customFieldInput.value,
);
}
if (field === "size") {
preferences[getPanelPatternPreferenceKey(target, "customSize")] = normalizePatternCustomSize(
customFieldInput.value,
);
}
preferences[patternKey] = "custom";
applyPreferences(preferences);
savePreferences(preferences);
}
});
patternIsolationInput?.addEventListener("input", () => {
if (!panelFeatures.allowPanelPatternChange) {
return;
}
preferences.patternIsolation = clampPatternIsolation(patternIsolationInput.value);
applyPreferences(preferences);
savePreferences(preferences);
});
// Изменение параметров своего градиента сразу переключает accent на custom.
function handleCustomAccentChange() {
if (!panelFeatures.allowAccentChange) {
return;
}
preferences.customAccentFrom = customAccentFromInput.value;
preferences.customAccentTo = customAccentToInput.value;
preferences.customAccentAngle = Number(customAccentAngleInput.value);
preferences.accent = "custom";
applyPreferences(preferences);
savePreferences(preferences);
}
customAccentFromInput.addEventListener("input", handleCustomAccentChange);
customAccentToInput.addEventListener("input", handleCustomAccentChange);
customAccentAngleInput.addEventListener("input", handleCustomAccentChange);
// Показывает/скрывает номера в секциях, группах и карточках.
showNumbersInput.addEventListener("change", () => {
if (!panelFeatures.allowShowNumbers) {
return;
}
preferences.showNumbers = showNumbersInput.checked;
applyPreferences(preferences);
savePreferences(preferences);
});
// Показывает/скрывает поисковые теги в файловом дереве.
showTreeTagsInput.addEventListener("change", () => {
if (!panelFeatures.allowTreeOptions || !isTreeVisible(preferences)) {
return;
}
preferences.showTreeTags = showTreeTagsInput.checked;
applyPreferences(preferences);
renderNavigationTree();
savePreferences(preferences);
});
showTreeTypeInput.addEventListener("change", () => {
if (!panelFeatures.allowTreeOptions || !isTreeVisible(preferences)) {
return;
}
preferences.showTreeType = showTreeTypeInput.checked;
applyPreferences(preferences);
renderNavigationTree();
savePreferences(preferences);
});
function handleTreeColumnsChange() {
if (!panelFeatures.allowTreeOptions || !isTreeVisible(preferences)) {
return;
}
preferences.treeColumns = getTreeColumns({ treeColumns: treeColumnsInput.value });
if (preferences.treeColumns > 1 && preferences.sidebarWidth < getSidebarMinWidth(preferences) + preferences.treeColumns * 70) {
preferences.sidebarWidth = getSidebarMinWidth(preferences) + preferences.treeColumns * 70;
}
applyPreferences(preferences);
savePreferences(preferences);
}
if (treeColumnsInput) {
treeColumnsInput.addEventListener("input", handleTreeColumnsChange);
}
function handleSettingsPanelWidthChange() {
preferences.settingsPanelWidth = clampLayoutPreference(
"settingsPanelWidth",
settingsPanelWidthInput?.value,
);
applyPreferences(preferences);
savePreferences(preferences);
}
settingsPanelWidthInput?.addEventListener("input", handleSettingsPanelWidthChange);
treeMetaLayoutButtons.forEach((button) => {
button.addEventListener("click", () => {
if (!panelFeatures.allowTreeOptions || !isTreeVisible(preferences)) {
return;
}
preferences.treeMetaLayout = button.dataset.treeMetaLayout;
applyPreferences(preferences);
savePreferences(preferences);
});
});
// Включает/выключает авто-раскрытие веток дерева при поиске.
autoCollapseSearchInput.addEventListener("change", () => {
if (!panelFeatures.allowTreeOptions || !isTreeVisible(preferences)) {
return;
}
preferences.autoCollapseSearch = autoCollapseSearchInput.checked;
applyPreferences(preferences);
renderNavigationTree();
savePreferences(preferences);
});
layoutLimitInputs.forEach((input) => {
input.addEventListener("input", () => {
if (!panelFeatures.allowLayoutLimitsChange) {
return;
}
const key = input.dataset.layoutLimit;
preferences[key] = clampLayoutPreference(key, input.value);
preferences = normalizePreferences(preferences);
applyPreferences(preferences);
savePreferences(preferences);
});
});
catalogColumnInputs.forEach((input) => {
input.addEventListener("input", () => {
if (!panelFeatures.allowCatalogColumnsChange) {
return;
}
const key = input.dataset.catalogColumns;
preferences[key] = clampCatalogColumn(key, input.value);
preferences = normalizePreferences(preferences);
applyPreferences(preferences);
savePreferences(preferences);
});
});
collapseAnimationButtons.forEach((button) => {
button.addEventListener("click", () => {
preferences.collapseAnimation = button.dataset.collapseAnimation;
applyPreferences(preferences);
savePreferences(preferences);
});
});
function handleCollapseDurationChange() {
preferences.collapseDuration = clampCollapseDuration(collapseDurationInput?.value);
applyPreferences(preferences);
savePreferences(preferences);
}
collapseDurationInput?.addEventListener("input", handleCollapseDurationChange);
// Drag-resize бокового меню.
// Ширина сохраняется в preferences.sidebarWidth и применяется через CSS-переменную.
sidebarResizer.addEventListener("pointerdown", (event) => {
if (preferences.navLayout === "top" || !isResizeVisible(preferences)) {
return;
}
event.preventDefault();
document.body.classList.add("is-resizing-sidebar");
sidebarResizer.setPointerCapture(event.pointerId);
const handlePointerMove = (moveEvent) => {
const layoutLeft = document.querySelector(".app-layout").getBoundingClientRect().left;
preferences.sidebarWidth = Math.round(
clamp(moveEvent.clientX - layoutLeft, getSidebarMinWidth(preferences), getSidebarMaxWidth(preferences)),
);
applyPreferences(preferences);
};
const handlePointerUp = () => {
document.body.classList.remove("is-resizing-sidebar");
savePreferences(preferences);
sidebarResizer.removeEventListener("pointermove", handlePointerMove);
sidebarResizer.removeEventListener("pointerup", handlePointerUp);
sidebarResizer.removeEventListener("pointercancel", handlePointerUp);
};
sidebarResizer.addEventListener("pointermove", handlePointerMove);
sidebarResizer.addEventListener("pointerup", handlePointerUp);
sidebarResizer.addEventListener("pointercancel", handlePointerUp);
});
// Drag-resize верхней панели в режиме navLayout: top.
topBarResizer?.addEventListener("pointerdown", (event) => {
if (preferences.navLayout !== "top" || !isResizeVisible(preferences)) {
return;
}
event.preventDefault();
document.body.classList.add("is-resizing-top-bar");
topBarResizer.setPointerCapture(event.pointerId);
const panelTop = document.querySelector(".side-panel")?.getBoundingClientRect().top ?? 0;
const handlePointerMove = (moveEvent) => {
preferences.topBarHeight = Math.round(
clamp(moveEvent.clientY - panelTop, getTopBarMinHeight(preferences), getTopBarMaxHeight(preferences)),
);
applyPreferences(preferences);
};
const handlePointerUp = () => {
document.body.classList.remove("is-resizing-top-bar");
savePreferences(preferences);
topBarResizer.removeEventListener("pointermove", handlePointerMove);
topBarResizer.removeEventListener("pointerup", handlePointerUp);
topBarResizer.removeEventListener("pointercancel", handlePointerUp);
};
topBarResizer.addEventListener("pointermove", handlePointerMove);
topBarResizer.addEventListener("pointerup", handlePointerUp);
topBarResizer.addEventListener("pointercancel", handlePointerUp);
});
// ============================================================================
// Panel config loading
// ============================================================================
async function loadPanelDataFromSettings(settings) {
const dataUrl = typeof settings.dataUrl === "string" ? settings.dataUrl.trim() : "";
let panelSource = settings.data;
if (dataUrl) {
panelSource = await fetchJson(dataUrl);
}
if (!panelSource || typeof panelSource !== "object") {
throw new Error("settings.json должен содержать data или dataUrl");
}
return normalizePanelData(panelSource);
}
function applyOrganizationConfig(settings) {
panelLimits = normalizePanelLimits(settings?.limits);
panelDefaults = normalizePreferences({
...defaultPreferences,
...(settings?.defaults && typeof settings.defaults === "object" ? settings.defaults : {}),
});
panelFeatures = {
...defaultPanelFeatures,
...(settings?.features && typeof settings.features === "object" ? settings.features : {}),
};
const placeholder =
typeof settings?.defaults?.searchPlaceholder === "string"
? settings.defaults.searchPlaceholder.trim()
: panelDefaults.searchPlaceholder;
if (searchInput && placeholder) {
searchInput.placeholder = placeholder;
}
}
function normalizePreferences(raw) {
const next = { ...raw };
if (!availableViews.includes(next.view)) {
next.view = defaultPreferences.view;
}
if (!availableAccents.includes(next.accent)) {
next.accent = defaultPreferences.accent;
}
migrateLegacyPanelPatternPreferences(next);
normalizePanelPatternPreferences(next);
next.patternIsolation = clampPatternIsolation(next.patternIsolation);
if (!availableNavLayouts.includes(next.navLayout)) {
next.navLayout = defaultPreferences.navLayout;
}
next.customAccentFrom =
normalizeHexColor(next.customAccentFrom) || defaultPreferences.customAccentFrom;
next.customAccentTo = normalizeHexColor(next.customAccentTo) || defaultPreferences.customAccentTo;
next.customAccentAngle = clamp(
Number(next.customAccentAngle) || defaultPreferences.customAccentAngle,
0,
360,
);
next.showNumbers = Boolean(next.showNumbers);
next.showTree = Boolean(next.showTree);
next.showResizeHandles = next.showResizeHandles !== false;
next.showTreeTags = Boolean(next.showTreeTags);
next.showTreeType = Boolean(next.showTreeType);
next.treeColumns = getTreeColumns(next);
next.treeMetaLayout = availableTreeMetaLayouts.includes(next.treeMetaLayout)
? next.treeMetaLayout
: defaultPreferences.treeMetaLayout;
next.autoCollapseSearch = Boolean(next.autoCollapseSearch);
normalizeLayoutPreferences(next);
normalizeCatalogColumns(next);
next.sidebarWidth = clamp(
Number(next.sidebarWidth) || defaultPreferences.sidebarWidth,
getSidebarMinWidth(next),
getSidebarMaxWidth(next),
);
next.topBarHeight = clamp(
Number(next.topBarHeight) || defaultPreferences.topBarHeight,
getTopBarMinHeight(next),
getTopBarMaxHeight(next),
);
next.settingsPanelWidth = clampLayoutPreference("settingsPanelWidth", next.settingsPanelWidth);
next.collapseAnimation = availableCollapseAnimations.includes(next.collapseAnimation)
? next.collapseAnimation
: defaultPreferences.collapseAnimation;
next.collapseDuration = clampCollapseDuration(next.collapseDuration);
return next;
}
function normalizeLayoutPreferences(next) {
for (const key of layoutLimitKeys) {
next[key] = clampLayoutPreference(key, next[key]);
}
if (next.sidebarMinWidth > next.sidebarMaxWidth) {
next.sidebarMinWidth = next.sidebarMaxWidth;
}
if (next.sidebarMaxWidth > next.sidebarMaxWidthWide) {
next.sidebarMaxWidth = next.sidebarMaxWidthWide;
}
if (next.topBarMinHeight > next.topBarMaxHeight) {
next.topBarMinHeight = next.topBarMaxHeight;
}
const minShellWidth = getSidebarMaxWidth(next) + 320;
if (next.shellMaxWidth < minShellWidth) {
next.shellMaxWidth = clampLayoutPreference("shellMaxWidth", minShellWidth);
}
}
function normalizeCatalogColumns(next) {
for (const key of catalogColumnKeys) {
next[key] = clampCatalogColumn(key, next[key]);
}
}
function enforceFeatureLocks(rawPreferences) {
const next = normalizePreferences(rawPreferences);
if (!panelFeatures.allowNavLayoutChange) {
next.navLayout = panelDefaults.navLayout;
}
if (!panelFeatures.allowShowTreeChange) {
next.showTree = panelDefaults.showTree;
}
if (!panelFeatures.allowThemeChange) {
next.theme = panelDefaults.theme;
}
if (!panelFeatures.allowViewChange) {
next.view = panelDefaults.view;
}
if (!panelFeatures.allowCatalogColumnsChange) {
for (const key of catalogColumnKeys) {
next[key] = panelDefaults[key];
}
}
if (!panelFeatures.allowAccentChange) {
next.accent = panelDefaults.accent;
next.customAccentFrom = panelDefaults.customAccentFrom;
next.customAccentTo = panelDefaults.customAccentTo;
next.customAccentAngle = panelDefaults.customAccentAngle;
}
if (!panelFeatures.allowPanelPatternChange) {
for (const target of panelPatternTargetIds) {
const cap = capitalizePanelPatternTarget(target);
next[`panelPattern${cap}`] = panelDefaults[`panelPattern${cap}`];
next[`panelPattern${cap}Opacity`] = panelDefaults[`panelPattern${cap}Opacity`];
next[`panelPattern${cap}Height`] = panelDefaults[`panelPattern${cap}Height`];
next[`panelPattern${cap}Fade`] = panelDefaults[`panelPattern${cap}Fade`];
next[`panelPattern${cap}GradientOpacity`] = panelDefaults[`panelPattern${cap}GradientOpacity`];
next[`panelPattern${cap}GradientHeight`] = panelDefaults[`panelPattern${cap}GradientHeight`];
next[`panelPattern${cap}GradientFade`] = panelDefaults[`panelPattern${cap}GradientFade`];
next[`panelPattern${cap}CustomImage`] = panelDefaults[`panelPattern${cap}CustomImage`];
next[`panelPattern${cap}CustomSize`] = panelDefaults[`panelPattern${cap}CustomSize`];
}
next.patternIsolation = panelDefaults.patternIsolation;
}
if (!panelFeatures.allowShowNumbers) {
next.showNumbers = panelDefaults.showNumbers;
}
if (!panelFeatures.allowTreeOptions || !next.showTree) {
next.showTreeTags = panelDefaults.showTreeTags;
next.showTreeType = panelDefaults.showTreeType;
next.treeColumns = panelDefaults.treeColumns;
next.treeMetaLayout = panelDefaults.treeMetaLayout;
next.autoCollapseSearch = panelDefaults.autoCollapseSearch;
}
if (!panelFeatures.allowSidebarResize) {
next.showResizeHandles = panelDefaults.showResizeHandles;
}
if (!panelFeatures.allowSidebarResize || next.navLayout === "top") {
next.sidebarWidth = panelDefaults.sidebarWidth;
}
if (!panelFeatures.allowSidebarResize || next.navLayout !== "top") {
next.topBarHeight = panelDefaults.topBarHeight;
}
if (!panelFeatures.allowLayoutLimitsChange) {
for (const key of layoutLimitKeys) {
next[key] = panelDefaults[key];
}
normalizeLayoutPreferences(next);
}
return next;
}
async function loadPanelConfig(settingsPath = SETTINGS_PATH) {
const settings = await fetchJson(settingsPath);
return loadPanelDataFromSettings(settings);
}
async function fetchJson(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Не удалось загрузить ${url}: HTTP ${response.status}`);
}
return response.json();
}
function normalizePanelData(raw) {
const rootItems = Array.isArray(raw.items)
? raw.items.map((item, index) => normalizeItemFromConfig(item, index))
: [];
let sections = Array.isArray(raw.sections) ? raw.sections.map(normalizeSection) : [];
// Корневой items[] без sections — плоский список карточек.
if (!sections.length && rootItems.length) {
sections = [
{
id: "root",
number: "",
title: typeof raw.title === "string" && raw.title.trim() ? raw.title.trim() : "Панель экосистемы",
description: String(raw.description ?? ""),
groups: [],
items: rootItems,
flat: true,
},
];
}
if (!sections.length) {
throw new Error("В данных панели нет sections или items");
}
const panelTitle =
typeof raw.title === "string" && raw.title.trim()
? raw.title.trim()
: sections[0]?.flat
? sections[0].title
: "Панель экосистемы";
return {
title: panelTitle,
sections,
serviceMeta: {
...defaultServiceMeta,
...(raw.serviceMeta && typeof raw.serviceMeta === "object" ? raw.serviceMeta : {}),
},
};
}
function normalizeSection(section) {
if (!section?.id || !section?.title) {
throw new Error("Каждый section должен содержать id и title");
}
const groups = Array.isArray(section.groups) ? section.groups.map(normalizeGroup) : [];
const items = Array.isArray(section.items)
? section.items.map((item, index) => normalizeItemFromConfig(item, index))
: [];
if (!groups.length && !items.length) {
throw new Error(`Раздел "${section.title}" должен содержать groups или items`);
}
return {
id: String(section.id),
number: String(section.number ?? ""),
title: String(section.title),
description: String(section.description ?? ""),
groups,
items,
flat: Boolean(section.flat),
};
}
function normalizeGroup(group) {
if (!group?.id || !group?.title) {
throw new Error("Каждый group должен содержать id и title");
}
return {
id: String(group.id),
number: String(group.number ?? ""),
title: String(group.title),
description: String(group.description ?? ""),
items: Array.isArray(group.items) ? group.items.map(normalizeItemFromConfig) : [],
};
}
function normalizeItemFromConfig(item, index = 0) {
if (!item?.title || !item?.type) {
throw new Error("Каждый item должен содержать title и type");
}
const itemKey = item.id
? String(item.id)
: item.number
? String(item.number)
: `auto-${index}-${slugifyItemKey(item.title)}`;
return {
itemKey,
number: String(item.number ?? ""),
title: String(item.title),
type: String(item.type),
tags: Array.isArray(item.tags) ? item.tags.map(String) : [],
url: typeof item.url === "string" && item.url.trim() ? item.url.trim() : "#",
icon: typeof item.icon === "string" && item.icon.trim() ? item.icon.trim() : "",
};
}
function getItemMeta(item) {
const typeMeta = serviceMeta[item.type] || serviceMeta.config;
if (item.icon) {
return {
label: typeMeta.label,
icon: item.icon,
};
}
return typeMeta;
}
function slugifyItemKey(value) {
return normalize(String(value))
.replace(/[^a-z0-9\u0430-\u044f\u0451]+/g, "-")
.replace(/^-+|-+$/g, "") || "item";
}
function showConfigError(error) {
const message = error instanceof Error ? error.message : String(error);
navTree.replaceChildren();
catalog.replaceChildren();
if (configErrorTemplate) {
const node = configErrorTemplate.content.cloneNode(true);
node.querySelector("[data-config-error-message]").textContent =
`${message}. Проверь settings.json и доступность dataUrl.`;
catalog.append(node);
return;
}
catalog.textContent = message;
}
// ============================================================================
// Data helpers
// ============================================================================
// Упрощает запись item-объектов при ручной правке JS (legacy helper).
function createItem(number, title, type, tags = [], url = "#") {
return normalizeItemFromConfig({ number, title, type, tags, url }, 0);
}
// ============================================================================
// Catalog rendering
// ============================================================================
// Рендерит правую часть: details-разделы, details-подразделы и карточки.
// При активном поиске:
// - оставляет только группы с найденными элементами;
// - принудительно раскрывает найденные ветки;
// - подсвечивает совпадения через highlightText().
function renderCatalog() {
const query = normalize(searchInput.value);
const forceOpen = Boolean(query);
const fragment = document.createDocumentFragment();
let visibleItems = 0;
ecosystemSections.forEach((section) => {
if (section.flat && !section.groups.length) {
const filteredItems = section.items.filter((item) => matchesQuery(item, null, section, query));
if (!filteredItems.length) {
return;
}
fragment.append(createItemsGridElement(filteredItems, query, "items-grid catalog-flat-grid"));
visibleItems += filteredItems.length;
return;
}
const sectionElement = createSectionElement(section, forceOpen, query);
let sectionVisibleItems = 0;
const sectionContainer = sectionElement.querySelector(".section-groups");
const filteredDirectItems = section.items.filter((item) => matchesQuery(item, null, section, query));
if (filteredDirectItems.length) {
sectionVisibleItems += filteredDirectItems.length;
sectionContainer.append(createItemsGridElement(filteredDirectItems, query));
}
section.groups.forEach((group) => {
const filteredItems = group.items.filter((item) => matchesQuery(item, group, section, query));
if (!filteredItems.length) {
return;
}
sectionVisibleItems += filteredItems.length;
sectionContainer.append(createGroupElement(group, filteredItems, forceOpen, query));
});
if (sectionVisibleItems) {
sectionElement.querySelector("[data-section-count]").textContent = sectionVisibleItems;
fragment.append(sectionElement);
visibleItems += sectionVisibleItems;
}
});
catalog.replaceChildren();
if (visibleItems) {
catalog.append(fragment);
attachCardInteractions();
} else {
catalog.append(emptyTemplate.content.cloneNode(true));
}
refreshCollapseAnimBodies();
}
function createItemsGridElement(items, query = "", className = "items-grid") {
const grid = document.createElement("div");
grid.className = className;
grid.append(...items.map((item) => createCardElement(item, query)));
return grid;
}
// Создает корневой details для раздела контента.
// forceOpen нужен для поиска: найденные ветки должны быть видимы независимо
// от сохраненного collapsed-состояния.
function createSectionElement(section, forceOpen = false, query = "") {
const sectionElement = document.createElement("details");
sectionElement.className = "catalog-section";
sectionElement.id = getSectionId(section);
sectionElement.dataset.collapseKey = sectionElement.id;
sectionElement.tabIndex = -1;
sectionElement.open = forceOpen || getSavedOpenState(sectionElement.dataset.collapseKey);
sectionElement.innerHTML = `
<summary class="section-head">
<div>
<p class="section-number">${highlightText(section.number, query)} раздел</p>
<h2>${highlightText(section.title, query)}</h2>
<p>${highlightText(section.description, query)}</p>
</div>
<span class="section-meta">
<span class="section-count"><span data-section-count>0</span> элементов</span>
<span class="collapse-indicator" aria-hidden="true">⌄</span>
</span>
</summary>
<div class="section-groups"></div>
`;
return sectionElement;
}
// Создает details-подраздел внутри раздела.
// items уже отфильтрованы на уровне renderCatalog().
function createGroupElement(group, items, forceOpen = false, query = "") {
const groupElement = document.createElement("details");
groupElement.className = "catalog-group";
groupElement.id = getGroupId(group);
groupElement.dataset.collapseKey = groupElement.id;
groupElement.tabIndex = -1;
groupElement.open = forceOpen || getSavedOpenState(groupElement.dataset.collapseKey);
const cards = items.map((item) => createCardElement(item, query));
groupElement.innerHTML = `
<summary class="group-head">
<div>
<p class="group-number">${highlightText(group.number, query)}</p>
<h3>${highlightText(group.title, query)}</h3>
<p>${highlightText(group.description, query)}</p>
</div>
<span class="group-meta">
<span class="group-count">${items.length}</span>
<span class="collapse-indicator" aria-hidden="true">⌄</span>
</span>
</summary>
`;
const grid = document.createElement("div");
grid.className = "items-grid";
grid.append(...cards);
groupElement.append(grid);
return groupElement;
}
// Создает конечную карточку навигации.
// Если url === "#", карточка считается заглушкой и не меняет текущий hash/scroll.
function createCardElement(item, query = "") {
const meta = getItemMeta(item);
const link = document.createElement("a");
link.className = "nav-card";
link.href = item.url;
link.id = getItemId(item);
link.dataset.type = item.type;
if (item.url === "#") {
link.setAttribute("aria-label", `${item.title}: URL еще не настроен`);
link.addEventListener("click", (event) => event.preventDefault());
} else {
link.target = "_blank";
link.rel = "noopener noreferrer";
}
link.innerHTML = `
<span class="shine"></span>
<img src="${resolveAssetPath(meta.icon)}" alt="" loading="lazy" />
<div class="card-content">
<p class="item-number">${highlightText(item.number, query)}</p>
<h4>${highlightText(item.title, query)}</h4>
<p class="item-type">${highlightText(meta.label, query)}</p>
<div class="tags">${item.tags.map((tag) => `<span>${highlightText(tag, query)}</span>`).join("")}</div>
</div>
<span class="arrow" aria-hidden="true">→</span>
`;
return link;
}
// ============================================================================
// Search matching and highlighting
// ============================================================================
// Проверяет, подходит ли конкретный item под строку поиска.
// Поиск идет не только по title, но и по номеру, type, label, tags и родителям.
function matchesQuery(item, group, section, query) {
if (!query) {
return true;
}
const meta = serviceMeta[item.type] || serviceMeta.config;
const haystack = normalize(
[
item.number,
item.title,
item.type,
meta.label,
item.tags.join(" "),
group?.title ?? "",
group?.description ?? "",
section.title,
].join(" "),
);
return haystack.includes(query);
}
function sectionMatchesQuery(section, query) {
if (!query) {
return true;
}
return (
matchesText([section.number, section.title, section.description], query) ||
section.items.some((item) => matchesQuery(item, null, section, query)) ||
section.groups.some((group) => groupMatchesQuery(group, section, query))
);
}
// Проверяет, есть ли совпадение на уровне группы или ее элементов.
// Используется для авто-раскрытия дерева при поиске.
function groupMatchesQuery(group, section, query) {
if (!query) {
return true;
}
return (
matchesText([group.number, group.title, group.description, section.title], query) ||
group.items.some((item) => matchesQuery(item, group, section, query))
);
}
// Проверяет произвольный набор строк на наличие нормализованного query.
function matchesText(values, query) {
if (!query) {
return true;
}
return normalize(values.join(" ")).includes(query);
}
// ============================================================================
// Sidebar tree rendering
// ============================================================================
// Строит левое файловое дерево из той же структуры данных, что и каталог.
// Это важно: дерево и карточки всегда остаются синхронизированными.
function renderNavigationTree() {
if (!isTreeVisible(preferences)) {
navTree.replaceChildren();
return;
}
const query = normalize(searchInput.value);
const shouldAutoCollapse = Boolean(query && preferences.autoCollapseSearch);
const fragment = document.createDocumentFragment();
ecosystemSections.forEach((section) => {
if (section.flat && !section.groups.length) {
const filteredItems = section.items.filter((item) => matchesQuery(item, null, section, query));
if (!filteredItems.length) {
return;
}
fragment.append(createTreeItemList(filteredItems, query));
return;
}
const sectionHasMatches = sectionMatchesQuery(section, query);
const sectionNode = document.createElement("details");
sectionNode.className = "tree-section";
sectionNode.dataset.collapseKey = getTreeSectionId(section);
sectionNode.open = shouldAutoCollapse ? sectionHasMatches : getSavedOpenState(sectionNode.dataset.collapseKey);
const sectionSummary = document.createElement("summary");
sectionSummary.innerHTML = `
<span class="tree-caret" aria-hidden="true"></span>
<button class="tree-node tree-folder" type="button" data-focus-target="${getSectionId(section)}">
<span>${highlightText(section.title, query)}</span>
</button>
`;
sectionNode.append(sectionSummary);
const filteredDirectItems = section.items.filter((item) => matchesQuery(item, null, section, query));
if (filteredDirectItems.length) {
sectionNode.append(createTreeItemList(filteredDirectItems, query, "tree-items tree-items-direct"));
}
section.groups.forEach((group) => {
const groupHasMatches = groupMatchesQuery(group, section, query);
const groupNode = document.createElement("details");
groupNode.className = "tree-group";
groupNode.dataset.collapseKey = getTreeGroupId(group);
groupNode.open = shouldAutoCollapse ? groupHasMatches : getSavedOpenState(groupNode.dataset.collapseKey);
const groupSummary = document.createElement("summary");
groupSummary.innerHTML = `
<span class="tree-caret" aria-hidden="true"></span>
<button class="tree-node tree-folder" type="button" data-focus-target="${getGroupId(group)}">
<span>${highlightText(group.title, query)}</span>
</button>
`;
groupNode.append(groupSummary);
groupNode.append(createTreeItemList(group.items.filter((item) => matchesQuery(item, group, section, query)), query));
sectionNode.append(groupNode);
});
fragment.append(sectionNode);
});
navTree.replaceChildren(fragment);
refreshCollapseAnimBodies();
}
function createTreeItemList(items, query = "", className = "tree-items") {
const itemList = document.createElement("div");
itemList.className = className;
items.forEach((item) => {
itemList.append(createTreeItemButton(item, query));
});
return itemList;
}
function createTreeItemButton(item, query = "") {
const meta = getItemMeta(item);
const itemButton = document.createElement("button");
itemButton.type = "button";
itemButton.className = "tree-node tree-file";
itemButton.dataset.focusTarget = getItemId(item);
itemButton.innerHTML = `
<img class="tree-node-icon" src="${resolveAssetPath(meta.icon)}" alt="" loading="lazy" />
<div class="tree-file-body">
<span class="tree-file-title">${highlightText(item.title, query)}</span>
<div class="tree-file-meta">
<span class="tree-file-type">${highlightText(meta.label, query)}</span>
<div class="tree-tags tags">${renderTreeTagSpans(item.tags, query)}</div>
</div>
</div>
`;
return itemButton;
}
function renderTreeTagSpans(tags, query = "") {
if (!Array.isArray(tags) || !tags.length) {
return "";
}
return tags.map((tag) => `<span>${highlightText(tag, query)}</span>`).join("");
}
// ============================================================================
// Focusing from tree to content
// ============================================================================
// Переход из дерева к правому контенту:
// - если текущий поиск скрывает цель, поиск очищается;
// - родительские details раскрываются;
// - состояние раскрытия сохраняется;
// - целевой элемент скроллится в центр и подсвечивается.
function focusCatalogTarget(targetId) {
let target = document.getElementById(targetId);
if (!target) {
searchInput.value = "";
renderCatalog();
target = document.getElementById(targetId);
}
if (!target) {
return;
}
target.closest(".catalog-section")?.setAttribute("open", "");
target.closest(".catalog-group")?.setAttribute("open", "");
persistOpenedAncestor(target.closest(".catalog-section"));
persistOpenedAncestor(target.closest(".catalog-group"));
target.scrollIntoView({ behavior: "smooth", block: "center" });
target.focus({ preventScroll: true });
target.classList.add("is-focused");
window.setTimeout(() => target.classList.remove("is-focused"), 1200);
}
// ============================================================================
// Collapse state handling
// ============================================================================
function isCollapseAnimationEnabled() {
return (
preferences.collapseAnimation === "animated" &&
!window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
}
function isCollapsibleDetails(details) {
return details instanceof HTMLDetailsElement && details.matches(COLLAPSIBLE_DETAILS_SELECTOR);
}
function shouldSkipAnimatedSummaryClick(event, summary) {
if (event.target.closest("a, input, textarea, select, label")) {
return true;
}
if (summary.closest(".tree-section, .tree-group") && event.target.closest("button")) {
return true;
}
return false;
}
function ensureCollapseAnimBody(details) {
if (!isCollapsibleDetails(details) || details.querySelector(":scope > .collapse-anim-body")) {
return details?.querySelector(":scope > .collapse-anim-body") ?? null;
}
const wrapper = document.createElement("div");
wrapper.className = "collapse-anim-body";
[...details.children].forEach((child) => {
if (child.tagName !== "SUMMARY") {
wrapper.append(child);
}
});
if (!wrapper.childNodes.length) {
return null;
}
details.append(wrapper);
return wrapper;
}
function refreshCollapseAnimBodies() {
if (!isCollapseAnimationEnabled()) {
return;
}
document.querySelectorAll(COLLAPSIBLE_DETAILS_SELECTOR).forEach((details) => {
ensureCollapseAnimBody(details);
});
}
function resetCollapseAnimBodyStyles(body) {
body.style.height = "";
body.style.transition = "";
body.style.overflow = "";
}
function getCollapseAnimDurationMs() {
return clampCollapseDuration(preferences.collapseDuration);
}
function animateCollapseBody(body, fromHeight, toHeight) {
return new Promise((resolve) => {
const duration = getCollapseAnimDurationMs();
let finished = false;
const finish = (event) => {
if (finished) {
return;
}
if (event && event.propertyName && event.propertyName !== "height") {
return;
}
finished = true;
body.removeEventListener("transitionend", finish);
resolve();
};
body.style.overflow = "hidden";
body.style.height = `${fromHeight}px`;
body.offsetHeight;
body.style.transition = `height ${duration}ms cubic-bezier(0.4, 0, 0.2, 1)`;
body.style.height = `${toHeight}px`;
body.addEventListener("transitionend", finish);
window.setTimeout(finish, duration + 50);
});
}
async function openDetailsAnimated(details) {
if (details.dataset.collapseAnimating === "true") {
return;
}
const body = ensureCollapseAnimBody(details);
if (!body) {
details.open = true;
return;
}
details.dataset.collapseAnimating = "true";
details.open = true;
resetCollapseAnimBodyStyles(body);
body.style.overflow = "hidden";
body.style.height = "0px";
body.offsetHeight;
await animateCollapseBody(body, 0, body.scrollHeight);
resetCollapseAnimBodyStyles(body);
delete details.dataset.collapseAnimating;
}
async function closeDetailsAnimated(details) {
if (details.dataset.collapseAnimating === "true") {
return;
}
const body = ensureCollapseAnimBody(details);
if (!body) {
details.open = false;
return;
}
details.dataset.collapseAnimating = "true";
const startHeight = body.scrollHeight;
await animateCollapseBody(body, startHeight, 0);
details.open = false;
resetCollapseAnimBodyStyles(body);
delete details.dataset.collapseAnimating;
}
function handleAnimatedDetailsClick(event) {
if (!isCollapseAnimationEnabled()) {
return;
}
const summary = event.target.closest("summary");
if (!summary) {
return;
}
const details = summary.closest("details");
if (!isCollapsibleDetails(details) || shouldSkipAnimatedSummaryClick(event, summary)) {
return;
}
event.preventDefault();
event.stopPropagation();
if (details.open) {
closeDetailsAnimated(details);
} else {
openDetailsAnimated(details);
}
}
function clearCollapseAnimInlineStyles() {
document.querySelectorAll(".collapse-anim-body").forEach((body) => {
resetCollapseAnimBodyStyles(body);
});
document.querySelectorAll(COLLAPSIBLE_DETAILS_SELECTOR).forEach((details) => {
delete details.dataset.collapseAnimating;
});
}
// Сохраняет состояние каждого details, у которого есть data-collapse-key.
function handleDetailsToggle(event) {
const details = event.target;
if (!(details instanceof HTMLDetailsElement) || !details.dataset.collapseKey) {
return;
}
collapseState[details.dataset.collapseKey] = details.open;
saveCollapseState();
}
// Принудительно сохраняет родительский details как раскрытый.
// Используется, когда пользователь прыгает к элементу из дерева.
function persistOpenedAncestor(details) {
if (!details?.dataset.collapseKey) {
return;
}
collapseState[details.dataset.collapseKey] = true;
saveCollapseState();
}
// ============================================================================
// Card pointer interaction
// ============================================================================
// Добавляет мягкий tilt/highlight эффект для карточек.
// После каждого renderCatalog() обработчики нужно навешивать заново,
// потому что карточки пересоздаются.
function attachCardInteractions() {
document.querySelectorAll(".nav-card").forEach((card) => {
let leaveTimer;
card.addEventListener("pointerenter", () => {
window.clearTimeout(leaveTimer);
card.classList.add("is-hovered");
});
card.addEventListener("pointermove", (event) => {
window.clearTimeout(leaveTimer);
card.classList.add("is-hovered");
const rect = card.getBoundingClientRect();
const xPercent = clamp(((event.clientX - rect.left) / rect.width) * 100, 8, 92);
const yPercent = clamp(((event.clientY - rect.top) / rect.height) * 100, 8, 92);
const rotateY = ((xPercent - 50) / 50) * 2.4;
const rotateX = ((50 - yPercent) / 50) * 2.4;
card.style.setProperty("--card-x", `${xPercent}%`);
card.style.setProperty("--card-y", `${yPercent}%`);
card.style.setProperty("--tilt-x", `${rotateX}deg`);
card.style.setProperty("--tilt-y", `${rotateY}deg`);
});
card.addEventListener("pointerleave", () => {
leaveTimer = window.setTimeout(() => {
card.classList.remove("is-hovered");
card.style.setProperty("--card-x", "50%");
card.style.setProperty("--card-y", "50%");
card.style.setProperty("--tilt-x", "0deg");
card.style.setProperty("--tilt-y", "0deg");
}, 80);
});
});
}
// ============================================================================
// Preferences application
// ============================================================================
// Применяет preferences к DOM:
// - data-theme / data-accent для CSS;
// - CSS-переменную ширины сайдбара;
// - класс режима отображения каталога;
// - состояния toggle-контролов в настройках.
function applyPreferences(nextPreferences) {
const lockedPreferences = enforceFeatureLocks(nextPreferences);
Object.assign(preferences, lockedPreferences);
const activePreferences = preferences;
document.documentElement.dataset.theme = activePreferences.theme;
document.documentElement.dataset.accent = activePreferences.accent;
applyPanelPatternPreferences(activePreferences);
applyPatternIsolationPreference(activePreferences);
document.documentElement.dataset.navLayout =
activePreferences.navLayout === "top" ? "top" : "sidebar";
document.documentElement.dataset.showTree = String(isTreeVisible(activePreferences));
document.documentElement.dataset.showResizeHandles = String(isResizeVisible(activePreferences));
applyAccentVariables(activePreferences);
document.documentElement.style.setProperty(
"--sidebar-width",
`${clamp(
Number(activePreferences.sidebarWidth) || defaultPreferences.sidebarWidth,
getSidebarMinWidth(activePreferences),
getSidebarMaxWidth(activePreferences),
)}px`,
);
document.documentElement.style.setProperty(
"--top-bar-height",
`${clamp(
Number(activePreferences.topBarHeight) || defaultPreferences.topBarHeight,
getTopBarMinHeight(activePreferences),
getTopBarMaxHeight(activePreferences),
)}px`,
);
document.documentElement.style.setProperty(
"--settings-panel-width",
`${clampLayoutPreference("settingsPanelWidth", activePreferences.settingsPanelWidth)}px`,
);
document.documentElement.dataset.collapseAnimation =
activePreferences.collapseAnimation === "animated" ? "animated" : "instant";
document.documentElement.style.setProperty(
"--collapse-duration",
`${clampCollapseDuration(activePreferences.collapseDuration)}ms`,
);
const treeColumns = getTreeColumns(activePreferences);
const sidebarMaxWidth = getSidebarMaxWidth(activePreferences);
document.documentElement.style.setProperty(
"--shell-max-width",
`${getShellMaxWidth(activePreferences)}px`,
);
document.documentElement.style.setProperty("--sidebar-max-width", `${sidebarMaxWidth}px`);
document.documentElement.style.setProperty(
"--tree-sidebar-base-width",
`${activePreferences.treeSidebarBaseWidth}px`,
);
document.documentElement.style.setProperty(
"--tree-sidebar-column-width",
`${activePreferences.treeSidebarColumnWidth}px`,
);
document.documentElement.style.setProperty(
"--catalog-cards-columns",
String(activePreferences.catalogCardsColumns),
);
document.documentElement.style.setProperty(
"--catalog-list-columns",
String(activePreferences.catalogListColumns),
);
document.documentElement.style.setProperty(
"--catalog-compact-cards-columns",
String(activePreferences.catalogCompactCardsColumns),
);
document.documentElement.style.setProperty(
"--catalog-compact-list-columns",
String(activePreferences.catalogCompactListColumns),
);
document.documentElement.style.setProperty("--tree-columns", String(treeColumns));
catalog.classList.remove("cards-view", "list-view", "compact-cards-view", "compact-list-view");
catalog.classList.add(`${activePreferences.view}-view`);
document.documentElement.dataset.showNumbers = String(Boolean(activePreferences.showNumbers));
document.documentElement.dataset.showTreeTags = String(Boolean(activePreferences.showTreeTags));
document.documentElement.dataset.showTreeType = String(Boolean(activePreferences.showTreeType));
document.documentElement.dataset.treeMetaLayout = activePreferences.treeMetaLayout;
document.documentElement.dataset.treeColumns = String(treeColumns);
if (showTreeInput) {
showTreeInput.checked = Boolean(activePreferences.showTree);
}
if (showResizeHandlesInput) {
showResizeHandlesInput.checked = Boolean(activePreferences.showResizeHandles);
}
showNumbersInput.checked = Boolean(activePreferences.showNumbers);
showTreeTagsInput.checked = Boolean(activePreferences.showTreeTags);
if (showTreeTypeInput) {
showTreeTypeInput.checked = Boolean(activePreferences.showTreeType);
}
if (treeColumnsInput) {
treeColumnsInput.value = String(treeColumns);
}
if (treeColumnsValue) {
treeColumnsValue.textContent = String(treeColumns);
}
autoCollapseSearchInput.checked = Boolean(activePreferences.autoCollapseSearch);
customAccentFromInput.value = activePreferences.customAccentFrom;
customAccentToInput.value = activePreferences.customAccentTo;
customAccentAngleInput.value = String(activePreferences.customAccentAngle);
customAccentAngleValue.textContent = `${activePreferences.customAccentAngle}°`;
accentCustomPanel.hidden = activePreferences.accent !== "custom";
updateCustomAccentPreview(activePreferences);
setActiveButton(themeButtons, "theme", activePreferences.theme);
setActiveButton(navLayoutButtons, "navLayout", activePreferences.navLayout);
setActiveButton(viewButtons, "view", activePreferences.view);
setActiveButton(accentButtons, "accent", activePreferences.accent);
syncPatternTargetPanels(activePreferences);
syncPatternIsolationControl(activePreferences);
setActiveButton(treeMetaLayoutButtons, "treeMetaLayout", activePreferences.treeMetaLayout);
syncLayoutLimitInputs(activePreferences);
syncCatalogColumnInputs(activePreferences);
syncSettingsPanelWidthInput(activePreferences);
syncCollapseAnimationControls(activePreferences);
refreshCollapseAnimBodies();
if (!isCollapseAnimationEnabled()) {
clearCollapseAnimInlineStyles();
}
applyPanelFeatures(activePreferences);
}
function applyPanelFeatures(activePreferences) {
settingsSections.forEach((section) => {
const featureName = section.dataset.settingsSection;
section.hidden = !isSettingsSectionVisible(featureName, activePreferences);
});
if (searchField) {
searchField.hidden = !panelFeatures.allowSearch;
}
if (navTree) {
navTree.hidden = !isTreeVisible(activePreferences);
}
if (sidebarResizer) {
sidebarResizer.hidden =
activePreferences.navLayout === "top" || !isResizeVisible(activePreferences);
}
if (topBarResizer) {
topBarResizer.hidden =
activePreferences.navLayout !== "top" || !isResizeVisible(activePreferences);
}
if (appLayout) {
appLayout.dataset.navLayout = activePreferences.navLayout === "top" ? "top" : "sidebar";
}
document.querySelectorAll("[data-feature]").forEach((element) => {
const featureName = element.dataset.feature;
element.hidden = featureName ? !panelFeatures[featureName] : false;
});
document.querySelectorAll("[data-show-when-sidebar]").forEach((element) => {
element.hidden = activePreferences.navLayout === "top";
});
}
function isSettingsSectionVisible(sectionName, activePreferences) {
switch (sectionName) {
case "layout":
return (
panelFeatures.allowNavLayoutChange ||
panelFeatures.allowShowTreeChange ||
panelFeatures.allowSidebarResize
);
case "dimensions":
return panelFeatures.allowLayoutLimitsChange;
case "catalog":
return (
panelFeatures.allowViewChange ||
panelFeatures.allowShowNumbers ||
panelFeatures.allowCatalogColumnsChange
);
case "tree":
return panelFeatures.allowTreeOptions && activePreferences.showTree;
case "appearance":
return (
panelFeatures.allowThemeChange ||
panelFeatures.allowAccentChange ||
panelFeatures.allowPanelPatternChange
);
default:
return true;
}
}
function isTreeVisible(activePreferences) {
return Boolean(activePreferences.showTree);
}
function isResizeVisible(activePreferences) {
return panelFeatures.allowSidebarResize && Boolean(activePreferences.showResizeHandles);
}
// Применяет CSS-переменные акцента для пресетов или пользовательского градиента.
function applyAccentVariables(nextPreferences) {
const root = document.documentElement;
if (nextPreferences.accent === "custom") {
root.style.setProperty("--accent", nextPreferences.customAccentFrom);
root.style.setProperty("--accent-2", nextPreferences.customAccentTo);
root.style.setProperty("--accent-angle", `${nextPreferences.customAccentAngle}deg`);
const rgb = hexToRgb(nextPreferences.customAccentFrom);
if (rgb) {
root.style.setProperty("--accent-rgb", `${rgb.r}, ${rgb.g}, ${rgb.b}`);
}
return;
}
root.style.removeProperty("--accent");
root.style.removeProperty("--accent-2");
root.style.removeProperty("--accent-rgb");
root.style.removeProperty("--accent-angle");
}
// Обновляет превью пользовательского градиента в настройках и на кнопке custom.
function updateCustomAccentPreview(nextPreferences) {
const gradient = `linear-gradient(${nextPreferences.customAccentAngle}deg, ${nextPreferences.customAccentFrom}, ${nextPreferences.customAccentTo})`;
customAccentPreview.style.background = gradient;
const customSwatch = document.querySelector('[data-accent="custom"]');
if (customSwatch) {
customSwatch.style.setProperty("--custom-accent-from", nextPreferences.customAccentFrom);
customSwatch.style.setProperty("--custom-accent-to", nextPreferences.customAccentTo);
customSwatch.style.setProperty("--custom-accent-angle", `${nextPreferences.customAccentAngle}deg`);
}
}
// Приводит hex-цвет к формату #rrggbb для input[type="color"].
function normalizeHexColor(value) {
if (typeof value !== "string") {
return null;
}
const normalized = value.trim().toLowerCase();
if (/^#[0-9a-f]{6}$/.test(normalized)) {
return normalized;
}
if (/^#[0-9a-f]{3}$/.test(normalized)) {
return `#${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}${normalized[3]}${normalized[3]}`;
}
return null;
}
// Конвертирует hex в RGB для CSS-переменной --accent-rgb.
function hexToRgb(value) {
const hex = normalizeHexColor(value);
if (!hex) {
return null;
}
return {
r: Number.parseInt(hex.slice(1, 3), 16),
g: Number.parseInt(hex.slice(3, 5), 16),
b: Number.parseInt(hex.slice(5, 7), 16),
};
}
// Обновляет визуальное состояние группы кнопок.
function setActiveButton(buttons, key, value) {
buttons.forEach((button) => {
const isActive = button.dataset[key] === value;
button.classList.toggle("is-active", isActive);
button.setAttribute("aria-pressed", String(isActive));
});
}
// ============================================================================
// Generic helpers
// ============================================================================
// Нормализует строки для поиска: регистр + ё/е.
function normalize(value) {
return value.toLowerCase().replaceAll("ё", "е").trim();
}
// Подсвечивает все совпадения query внутри value.
// Возвращает HTML-строку, поэтому весь обычный текст пропускается через escapeHTML().
function highlightText(value, query) {
const text = String(value);
const normalizedQuery = normalize(query);
if (!normalizedQuery) {
return escapeHTML(text);
}
const normalizedText = normalize(text);
let cursor = 0;
let result = "";
let matchIndex = normalizedText.indexOf(normalizedQuery, cursor);
while (matchIndex !== -1) {
result += escapeHTML(text.slice(cursor, matchIndex));
result += `<mark class="search-mark">${escapeHTML(text.slice(matchIndex, matchIndex + normalizedQuery.length))}</mark>`;
cursor = matchIndex + normalizedQuery.length;
matchIndex = normalizedText.indexOf(normalizedQuery, cursor);
}
return result + escapeHTML(text.slice(cursor));
}
// Минимальная HTML-экранизация для безопасной вставки строк в innerHTML.
function escapeHTML(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
// Стабильные id для секций, групп и элементов.
// Они используются одновременно как DOM id и как цель tree focus.
function getSectionId(section) {
return `section-${section.id}`;
}
function getGroupId(group) {
return `group-${group.id}`;
}
// Отдельные ключи для дерева, чтобы collapse-состояния дерева
// не конфликтовали с collapse-состояниями контента.
function getTreeSectionId(section) {
return `tree-${getSectionId(section)}`;
}
function getTreeGroupId(group) {
return `tree-${getGroupId(group)}`;
}
function getItemId(item) {
return `item-${String(item.itemKey).replaceAll(".", "-")}`;
}
// Ограничивает число диапазоном.
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function getTreeColumns(nextPreferences) {
let columns = Number(nextPreferences.treeColumns);
if (!Number.isFinite(columns)) {
columns = nextPreferences.treeTwoColumn ? 2 : defaultPreferences.treeColumns;
}
return clamp(Math.round(columns), MIN_TREE_COLUMNS, MAX_TREE_COLUMNS);
}
function getLayoutLimitRange(key) {
const limits = panelLimits[key] ?? defaultPanelLimits[key];
if (!limits || !Number.isFinite(limits.min) || !Number.isFinite(limits.max)) {
return defaultPanelLimits[key];
}
return {
min: Math.min(limits.min, limits.max),
max: Math.max(limits.min, limits.max),
};
}
function clampLayoutPreference(key, value) {
const limits = getLayoutLimitRange(key);
const fallback = panelDefaults[key] ?? defaultPreferences[key];
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return fallback;
}
return clamp(Math.round(numeric), limits.min, limits.max);
}
function normalizePanelLimits(rawLimits) {
const next = structuredClone(defaultPanelLimits);
if (!rawLimits || typeof rawLimits !== "object") {
return next;
}
for (const key of panelLimitKeys) {
const source = rawLimits[key];
if (!source || typeof source !== "object") {
continue;
}
const min = Math.round(Number(source.min));
const max = Math.round(Number(source.max));
if (!Number.isFinite(min) || !Number.isFinite(max)) {
continue;
}
next[key] = {
min: Math.min(min, max),
max: Math.max(min, max),
};
}
return next;
}
function syncLayoutLimitInputs(activePreferences) {
layoutLimitInputs.forEach((input) => {
const key = input.dataset.layoutLimit;
if (!layoutLimitKeys.includes(key)) {
return;
}
const limits = getPanelLimitRange(key);
input.min = String(limits.min);
input.max = String(limits.max);
input.step = key === "treeSidebarColumnWidth" ? "5" : "10";
input.value = String(activePreferences[key]);
input.disabled = !panelFeatures.allowLayoutLimitsChange;
const output = document.querySelector(`[data-layout-limit-value="${key}"]`);
if (output) {
output.textContent = String(activePreferences[key]);
}
});
}
function syncSettingsPanelWidthInput(activePreferences) {
if (!settingsPanelWidthInput) {
return;
}
const limits = getPanelLimitRange("settingsPanelWidth");
settingsPanelWidthInput.min = String(limits.min);
settingsPanelWidthInput.max = String(limits.max);
settingsPanelWidthInput.step = "10";
settingsPanelWidthInput.value = String(activePreferences.settingsPanelWidth);
if (settingsPanelWidthValue) {
settingsPanelWidthValue.textContent = `${activePreferences.settingsPanelWidth}px`;
}
}
function clampCollapseDuration(value) {
const numeric = Number(value);
const base = Number.isFinite(numeric) ? numeric : defaultPreferences.collapseDuration;
return clamp(Math.round(base), MIN_COLLAPSE_DURATION, MAX_COLLAPSE_DURATION);
}
function syncCollapseAnimationControls(activePreferences) {
setActiveButton(collapseAnimationButtons, "collapseAnimation", activePreferences.collapseAnimation);
if (collapseDurationInput) {
collapseDurationInput.min = String(MIN_COLLAPSE_DURATION);
collapseDurationInput.max = String(MAX_COLLAPSE_DURATION);
collapseDurationInput.step = "25";
collapseDurationInput.value = String(clampCollapseDuration(activePreferences.collapseDuration));
collapseDurationInput.disabled = activePreferences.collapseAnimation !== "animated";
}
if (collapseDurationValue) {
collapseDurationValue.textContent = `${clampCollapseDuration(activePreferences.collapseDuration)} ms`;
}
}
function syncCatalogColumnInputs(activePreferences) {
catalogColumnInputs.forEach((input) => {
const key = input.dataset.catalogColumns;
if (!catalogColumnKeys.includes(key)) {
return;
}
const limits = getPanelLimitRange(key);
input.min = String(limits.min);
input.max = String(limits.max);
input.step = "1";
input.value = String(activePreferences[key]);
input.disabled = !panelFeatures.allowCatalogColumnsChange;
const output = document.querySelector(`[data-catalog-columns-value="${key}"]`);
if (output) {
output.textContent = String(activePreferences[key]);
}
});
}
function getPanelLimitRange(key) {
return getLayoutLimitRange(key);
}
function clampPanelPatternPercent(value, fallback = 50) {
const numeric = Number(value);
const base = Number.isFinite(numeric) ? numeric : fallback;
return clamp(Math.round(base), 0, 100);
}
// ============================================================================
// Panel pattern preferences
// ============================================================================
// Нормализация, миграция legacy-ключей, применение к DOM и синхронизация UI ⚙.
// Ключи preferences: panelPatternNav, panelPatternNavOpacity, panelPatternNavCustomImage, …
function clampPatternIsolation(value) {
const numeric = Number(value);
const fallback = panelDefaults.patternIsolation ?? defaultPreferences.patternIsolation;
const base = Number.isFinite(numeric) ? numeric : fallback;
return clamp(Math.round(base), MIN_PATTERN_ISOLATION, MAX_PATTERN_ISOLATION);
}
function getPanelPatternPreferenceKey(target, field) {
const cap = capitalizePanelPatternTarget(target);
switch (field) {
case "pattern":
return `panelPattern${cap}`;
case "opacity":
return `panelPattern${cap}Opacity`;
case "height":
return `panelPattern${cap}Height`;
case "fade":
return `panelPattern${cap}Fade`;
case "gradientOpacity":
return `panelPattern${cap}GradientOpacity`;
case "gradientHeight":
return `panelPattern${cap}GradientHeight`;
case "gradientFade":
return `panelPattern${cap}GradientFade`;
case "customImage":
return `panelPattern${cap}CustomImage`;
case "customSize":
return `panelPattern${cap}CustomSize`;
default:
return `panelPattern${cap}`;
}
}
function migrateLegacyPanelPatternPreferences(next) {
if (next.panelPatternNav !== undefined) {
return;
}
const legacyPattern = availablePanelPatterns.includes(next.panelPattern)
? next.panelPattern
: panelPatternTargetMeta.nav.defaultPattern;
const legacyOpacity = next.panelPatternOpacity ?? panelPatternTuningDefaults.opacity;
const legacyHeight = next.panelPatternHeight ?? panelPatternTuningDefaults.height;
const legacyGradientOpacity = next.panelGradientOpacity ?? panelPatternTuningDefaults.gradientOpacity;
const legacyGradientHeight = next.panelGradientHeight ?? panelPatternTuningDefaults.gradientHeight;
const legacyTargets = [
["Nav", next.panelPatternOnNav !== false],
["Section", next.panelPatternOnSection !== false],
["Group", Boolean(next.panelPatternOnGroup)],
["Item", Boolean(next.panelPatternOnItem)],
];
for (const [suffix, enabled] of legacyTargets) {
next[`panelPattern${suffix}`] = enabled && legacyPattern !== "none" ? legacyPattern : "none";
next[`panelPattern${suffix}Opacity`] = legacyOpacity;
next[`panelPattern${suffix}Height`] = legacyHeight;
next[`panelPattern${suffix}Fade`] = panelPatternTuningDefaults.fade;
next[`panelPattern${suffix}GradientOpacity`] = legacyGradientOpacity;
next[`panelPattern${suffix}GradientHeight`] = legacyGradientHeight;
next[`panelPattern${suffix}GradientFade`] = panelPatternTuningDefaults.gradientFade;
}
}
function normalizePanelPatternPreferences(next) {
for (const target of panelPatternTargetIds) {
const cap = capitalizePanelPatternTarget(target);
const patternKey = `panelPattern${cap}`;
if (!availablePanelPatterns.includes(next[patternKey])) {
next[patternKey] = panelPatternTargetMeta[target].defaultPattern;
}
next[`panelPattern${cap}Opacity`] = clampPanelPatternPercent(
next[`panelPattern${cap}Opacity`],
panelPatternTuningDefaults.opacity,
);
next[`panelPattern${cap}Height`] = clampPanelPatternPercent(
next[`panelPattern${cap}Height`],
panelPatternTuningDefaults.height,
);
next[`panelPattern${cap}Fade`] = clampPanelPatternPercent(
next[`panelPattern${cap}Fade`],
panelPatternTuningDefaults.fade,
);
next[`panelPattern${cap}GradientOpacity`] = clampPanelPatternPercent(
next[`panelPattern${cap}GradientOpacity`],
panelPatternTuningDefaults.gradientOpacity,
);
next[`panelPattern${cap}GradientHeight`] = clampPanelPatternPercent(
next[`panelPattern${cap}GradientHeight`],
panelPatternTuningDefaults.gradientHeight,
);
next[`panelPattern${cap}GradientFade`] = clampPanelPatternPercent(
next[`panelPattern${cap}GradientFade`],
panelPatternTuningDefaults.gradientFade,
);
next[`panelPattern${cap}CustomImage`] = normalizePatternCustomImage(
next[`panelPattern${cap}CustomImage`],
);
next[`panelPattern${cap}CustomSize`] = normalizePatternCustomSize(
next[`panelPattern${cap}CustomSize`],
);
}
}
function getPanelPatternFadeStart(heightPercent, fadePercent) {
const height = clampPanelPatternPercent(heightPercent, panelPatternTuningDefaults.height);
const fade = clampPanelPatternPercent(fadePercent, panelPatternTuningDefaults.fade);
return `${Math.max(0, height - fade)}%`;
}
/**
* Пишет узоры на <html>: data-panel-pattern-{Nav|…}, opacity/height/fade,
* градиент и (для custom) inline --panel-pattern-image-{target}.
*/
function applyPanelPatternPreferences(activePreferences) {
for (const target of panelPatternTargetIds) {
const cap = capitalizePanelPatternTarget(target);
let pattern = activePreferences[`panelPattern${cap}`] || "none";
const customImage = normalizePatternCustomImage(activePreferences[`panelPattern${cap}CustomImage`]);
const customSize = normalizePatternCustomSize(activePreferences[`panelPattern${cap}CustomSize`]);
const height = clampPanelPatternPercent(
activePreferences[`panelPattern${cap}Height`],
panelPatternTuningDefaults.height,
);
const opacityPercent = clampPanelPatternPercent(
activePreferences[`panelPattern${cap}Opacity`],
panelPatternTuningDefaults.opacity,
);
let patternOpacity = pattern === "none" || height === 0 ? 0 : opacityPercent / 100;
if (pattern === "custom" && !customImage) {
patternOpacity = 0;
}
document.documentElement.dataset[`panelPattern${cap}`] = pattern;
document.documentElement.style.removeProperty(`--panel-pattern-image-${target}`);
document.documentElement.style.removeProperty(`--panel-pattern-size-${target}`);
if (pattern === "custom" && customImage) {
document.documentElement.style.setProperty(
`--panel-pattern-image-${target}`,
`url("${customImage.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}")`,
);
document.documentElement.style.setProperty(`--panel-pattern-size-${target}`, customSize);
}
document.documentElement.style.setProperty(`--panel-pattern-${target}-opacity`, String(patternOpacity));
document.documentElement.style.setProperty(`--panel-pattern-${target}-height`, `${height}%`);
document.documentElement.style.setProperty(
`--panel-pattern-${target}-fade-start`,
getPanelPatternFadeStart(height, activePreferences[`panelPattern${cap}Fade`]),
);
document.documentElement.style.setProperty(
`--panel-gradient-${target}-strength`,
String(
(clampPanelPatternPercent(
activePreferences[`panelPattern${cap}GradientOpacity`],
panelPatternTuningDefaults.gradientOpacity,
) /
100) *
0.28,
),
);
const gradientHeight = clampPanelPatternPercent(
activePreferences[`panelPattern${cap}GradientHeight`],
panelPatternTuningDefaults.gradientHeight,
);
document.documentElement.style.setProperty(
`--panel-gradient-${target}-height`,
`${gradientHeight}%`,
);
document.documentElement.style.setProperty(
`--panel-gradient-${target}-fade-start`,
getPanelPatternFadeStart(gradientHeight, activePreferences[`panelPattern${cap}GradientFade`]),
);
}
}
function applyPatternIsolationPreference(activePreferences) {
// patternIsolation 0–100 → blur и непрозрачность подложки на section/group/card
const isolation = clampPatternIsolation(activePreferences.patternIsolation);
document.documentElement.style.setProperty("--panel-layer-blur", `${Math.round(isolation * 0.18)}px`);
document.documentElement.style.setProperty(
"--panel-layer-opacity",
String(0.04 + (isolation / 100) * 0.5),
);
}
function syncPatternIsolationControl(activePreferences) {
const isolation = clampPatternIsolation(activePreferences.patternIsolation);
if (patternIsolationInput) {
patternIsolationInput.value = String(isolation);
patternIsolationInput.disabled = !panelFeatures.allowPanelPatternChange;
}
if (patternIsolationValue) {
patternIsolationValue.textContent = `${isolation}%`;
}
}
function syncPatternTargetPanels(activePreferences) {
if (!patternTargetsPanel) {
return;
}
const disabled = !panelFeatures.allowPanelPatternChange;
const tuningFields = ["opacity", "height", "fade", "gradientOpacity", "gradientHeight", "gradientFade"];
for (const target of panelPatternTargetIds) {
const pattern = activePreferences[getPanelPatternPreferenceKey(target, "pattern")];
const customImage = activePreferences[getPanelPatternPreferenceKey(target, "customImage")];
patternTargetsPanel
.querySelectorAll(`[data-panel-pattern-target="${target}"][data-panel-pattern]`)
.forEach((button) => {
const isActive = button.dataset.panelPattern === pattern;
button.classList.toggle("is-active", isActive);
button.setAttribute("aria-pressed", String(isActive));
button.disabled = disabled;
if (button.dataset.panelPattern === "custom" && customImage) {
button.style.setProperty("--pattern-swatch-preview", `url("${customImage.replace(/"/g, '\\"')}")`);
button.classList.add("has-custom-preview");
} else {
button.style.removeProperty("--pattern-swatch-preview");
button.classList.remove("has-custom-preview");
}
});
const customPanel = patternTargetsPanel.querySelector(`[data-pattern-custom-panel="${target}"]`);
if (customPanel) {
customPanel.hidden = pattern !== "custom";
const imageInput = customPanel.querySelector('[data-pattern-custom-field="image"]');
const sizeInput = customPanel.querySelector('[data-pattern-custom-field="size"]');
if (imageInput) {
imageInput.value = activePreferences[getPanelPatternPreferenceKey(target, "customImage")] || "";
imageInput.disabled = disabled;
}
if (sizeInput) {
sizeInput.value = activePreferences[getPanelPatternPreferenceKey(target, "customSize")];
sizeInput.disabled = disabled;
}
}
for (const field of tuningFields) {
const key = getPanelPatternPreferenceKey(target, field);
const input = patternTargetsPanel.querySelector(
`[data-panel-pattern-tuning="${target}"][data-panel-pattern-field="${field}"]`,
);
const output = patternTargetsPanel.querySelector(
`[data-panel-pattern-value="${target}"][data-panel-pattern-field="${field}"]`,
);
if (input) {
input.value = String(activePreferences[key]);
input.disabled = disabled;
}
if (output) {
output.textContent = `${activePreferences[key]}%`;
}
}
}
}
// --- Custom pattern URL / tile size ---
/** Принимает data:image/, http(s), относительные пути и *.svg|png|…; отклоняет javascript:. */
function normalizePatternCustomImage(value) {
const trimmed = String(value ?? "").trim();
if (!trimmed) {
return "";
}
if (/^javascript:/i.test(trimmed)) {
return "";
}
if (/^(?:data:image\/|https?:\/\/|\.\/|\.\.\/|\/|[a-z0-9._-]+\.(?:svg|png|webp|gif|jpg|jpeg)(?:\?|$))/i.test(trimmed)) {
return trimmed;
}
if (/^[a-z0-9._/-]+\.(?:svg|png|webp|gif|jpg|jpeg)$/i.test(trimmed)) {
return trimmed;
}
return "";
}
function normalizePatternCustomSize(value) {
const trimmed = String(value ?? "").trim();
const fallback = defaultPanelPatternCustomSize;
if (!trimmed) {
return fallback;
}
if (/^\d+(?:\.\d+)?px\s+\d+(?:\.\d+)?px$/.test(trimmed)) {
return trimmed;
}
const parts = trimmed.split(/[\s,]+/).filter(Boolean);
if (parts.length === 2 && parts.every((part) => /^\d+(?:\.\d+)?(?:px)?$/.test(part))) {
const width = parts[0].endsWith("px") ? parts[0] : `${parts[0]}px`;
const height = parts[1].endsWith("px") ? parts[1] : `${parts[1]}px`;
return `${width} ${height}`;
}
return fallback;
}
// --- Pattern UI in settings popup (dynamic swatches + custom panel) ---
/** Вызывается из bootstrap(): кнопки пресетов и поля «Свой узор» для каждой цели. */
function setupPatternControls() {
renderPatternSwatches();
ensurePatternCustomPanels();
}
function renderPatternSwatches() {
// Контейнеры [data-pattern-swatches] в index.html изначально пустые — кнопки создаются здесь
if (!patternTargetsPanel) {
return;
}
patternTargetsPanel.querySelectorAll("[data-pattern-swatches]").forEach((container) => {
const target = container.dataset.patternSwatches;
if (!target || container.dataset.rendered === "true") {
return;
}
container.dataset.rendered = "true";
container.replaceChildren(
...panelPatternCatalog.map(({ id, label }) => {
const button = document.createElement("button");
button.type = "button";
button.className = "pattern-swatch";
button.dataset.panelPatternTarget = target;
button.dataset.panelPattern = id;
button.setAttribute("aria-pressed", "false");
button.innerHTML = `<span class="pattern-swatch-label">${label}</span>`;
return button;
}),
);
});
}
function ensurePatternCustomPanels() {
// Вставляет .pattern-custom-panel перед .pattern-tuning в каждой data-pattern-target-panel
if (!patternTargetsPanel) {
return;
}
for (const target of panelPatternTargetIds) {
const panel = patternTargetsPanel.querySelector(`[data-pattern-target="${target}"]`);
if (!panel || panel.querySelector(`[data-pattern-custom-panel="${target}"]`)) {
continue;
}
const customPanel = document.createElement("div");
customPanel.className = "pattern-custom-panel";
customPanel.dataset.patternCustomPanel = target;
customPanel.hidden = true;
customPanel.innerHTML = `
<p class="settings-section-hint">Вставьте URL или путь к SVG/PNG ниже. Пока поле пустое, узор не рисуется.</p>
<label class="accent-custom-row pattern-custom-image-row">
<span>Изображение</span>
<input type="url" placeholder="./assets/my-pattern.svg" data-panel-pattern-target="${target}" data-pattern-custom-field="image" />
</label>
<label class="accent-custom-row">
<span>Размер плитки</span>
<input type="text" placeholder="32px 32px" data-panel-pattern-target="${target}" data-pattern-custom-field="size" />
</label>
`;
const tuning = panel.querySelector(".pattern-tuning");
panel.querySelector(".settings-section-body")?.insertBefore(customPanel, tuning ?? null);
}
}
function clampCatalogColumn(key, value) {
const limits = getPanelLimitRange(key);
const fallback = panelDefaults[key] ?? defaultPreferences[key];
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return fallback;
}
return clamp(Math.round(numeric), limits.min, limits.max);
}
function getSidebarMinWidth(nextPreferences) {
return Number(nextPreferences.sidebarMinWidth);
}
function getSidebarMaxWidth(nextPreferences) {
const columns = getTreeColumns(nextPreferences);
if (columns <= 1) {
return Number(nextPreferences.sidebarMaxWidth);
}
return Math.min(
Number(nextPreferences.sidebarMaxWidthWide),
Number(nextPreferences.treeSidebarBaseWidth) +
columns * Number(nextPreferences.treeSidebarColumnWidth),
);
}
function getShellMaxWidth(nextPreferences) {
return Number(nextPreferences.shellMaxWidth);
}
function getTopBarMinHeight(nextPreferences) {
return Number(nextPreferences.topBarMinHeight);
}
function getTopBarMaxHeight(nextPreferences) {
return Math.min(
Number(nextPreferences.topBarMaxHeight),
Math.round(window.innerHeight * 0.75),
);
}
// ============================================================================
// localStorage helpers
// ============================================================================
// Читает настройки интерфейса.
function readPreferences() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {};
} catch {
return {};
}
}
// Сохраняет настройки интерфейса.
function savePreferences(nextPreferences) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(nextPreferences));
}
// Возвращает сохраненное состояние details.
// По умолчанию все ветки открыты, чтобы первый вход был максимально понятным.
function getSavedOpenState(key) {
return collapseState[key] ?? true;
}
// Читает состояния раскрытия дерева/контента.
function readCollapseState() {
try {
return JSON.parse(localStorage.getItem(COLLAPSE_STORAGE_KEY)) || {};
} catch {
return {};
}
}
// Сохраняет состояния раскрытия дерева/контента.
function saveCollapseState() {
localStorage.setItem(COLLAPSE_STORAGE_KEY, JSON.stringify(collapseState));
}
// ============================================================================
// Settings import, export and reset
// ============================================================================
// Кнопки внизу popup ⚙: data-settings-reset | export | import.
// Формат экспорта: { version, exportedAt, preferences }. Импорт принимает и «голый» объект.
/** Применяет набор preferences, обновляет дерево/каталог и при необходимости сохраняет в localStorage. */
function applyStoredPreferences(rawPreferences, { persist = true } = {}) {
preferences = enforceFeatureLocks(normalizePreferences({ ...panelDefaults, ...rawPreferences }));
applyPreferences(preferences);
if (isTreeVisible(preferences)) {
renderNavigationTree();
} else if (navTree) {
navTree.replaceChildren();
}
renderCatalog();
if (persist) {
savePreferences(preferences);
}
}
// Сбрасывает пользовательские настройки к panelDefaults из settings.json.
function resetStoredPreferences() {
if (!window.confirm("Сбросить все настройки к значениям по умолчанию?")) {
return;
}
localStorage.removeItem(STORAGE_KEY);
applyStoredPreferences({ ...panelDefaults }, { persist: false });
}
// Скачивает текущие preferences в JSON-файл.
function exportStoredPreferences() {
const payload = {
version: 1,
exportedAt: new Date().toISOString(),
preferences: { ...preferences },
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "ecosystem-panel-preferences.json";
anchor.click();
URL.revokeObjectURL(url);
}
// Читает JSON-файл и возвращает объект preferences.
function readImportedPreferencesFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
try {
const parsed = JSON.parse(String(reader.result));
resolve(parseImportedPreferencesPayload(parsed));
} catch (error) {
reject(error instanceof Error ? error : new Error("Не удалось прочитать файл настроек."));
}
};
reader.onerror = () => {
reject(new Error("Не удалось прочитать файл настроек."));
};
reader.readAsText(file);
});
}
// Поддерживает как обёртку { preferences }, так и «голый» объект настроек.
function parseImportedPreferencesPayload(raw) {
const source =
raw && typeof raw === "object" && !Array.isArray(raw) && raw.preferences && typeof raw.preferences === "object"
? raw.preferences
: raw;
if (!source || typeof source !== "object" || Array.isArray(source)) {
throw new Error("Неверный формат файла настроек.");
}
return source;
}
// Закрывает popup-настройки и синхронизирует aria-expanded.
function closeSettingsPanel() {
settingsPanel.classList.remove("is-open");
settingsOverlay.classList.remove("is-open");
settingsToggle.setAttribute("aria-expanded", "false");
}