// 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-* на и 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 = `

${highlightText(section.number, query)} раздел

${highlightText(section.title, query)}

${highlightText(section.description, query)}

0 элементов
`; 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 = `

${highlightText(group.number, query)}

${highlightText(group.title, query)}

${highlightText(group.description, query)}

${items.length}
`; 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 = `

${highlightText(item.number, query)}

${highlightText(item.title, query)}

${highlightText(meta.label, query)}

${item.tags.map((tag) => `${highlightText(tag, query)}`).join("")}
`; 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 = ` `; 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 = ` `; 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 = `
${highlightText(item.title, query)}
${highlightText(meta.label, query)}
${renderTreeTagSpans(item.tags, query)}
`; return itemButton; } function renderTreeTagSpans(tags, query = "") { if (!Array.isArray(tags) || !tags.length) { return ""; } return tags.map((tag) => `${highlightText(tag, query)}`).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 += `${escapeHTML(text.slice(matchIndex, matchIndex + normalizedQuery.length))}`; cursor = matchIndex + normalizedQuery.length; matchIndex = normalizedText.indexOf(normalizedQuery, cursor); } return result + escapeHTML(text.slice(cursor)); } // Минимальная HTML-экранизация для безопасной вставки строк в innerHTML. function escapeHTML(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } // Стабильные 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)}%`; } /** * Пишет узоры на : 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 = `${label}`; 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 = `

Вставьте URL или путь к SVG/PNG ниже. Пока поле пустое, узор не рисуется.

`; 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"); }