diff --git a/index.html b/index.html index 88efaf5..cfeddbb 100644 --- a/index.html +++ b/index.html @@ -173,6 +173,45 @@ data-accent="amber" aria-label="Янтарный акцент" > + + + + + diff --git a/script.js b/script.js index 0bd275b..309e8ea 100644 --- a/script.js +++ b/script.js @@ -166,12 +166,16 @@ const defaultPreferences = { theme: window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark", view: "cards", accent: "violet", + customAccentFrom: "#8b5cf6", + customAccentTo: "#22d3ee", + customAccentAngle: 135, showNumbers: false, showTreeTags: true, autoCollapseSearch: true, sidebarWidth: 390, }; const availableViews = ["cards", "list", "compact-cards", "compact-list"]; +const availableAccents = ["violet", "cyan", "lime", "amber", "custom"]; // ============================================================================ // Runtime state and DOM references @@ -194,6 +198,12 @@ const settingsClose = document.querySelector("[data-settings-close]"); const themeButtons = document.querySelectorAll("[data-theme]"); const viewButtons = document.querySelectorAll("[data-view]"); const accentButtons = document.querySelectorAll("[data-accent]"); +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 showNumbersInput = document.querySelector("[data-show-numbers]"); const showTreeTagsInput = document.querySelector("[data-show-tree-tags]"); const autoCollapseSearchInput = document.querySelector("[data-auto-collapse-search]"); @@ -285,7 +295,7 @@ viewButtons.forEach((button) => { }); }); -// Выбор цветового акцента. +// Выбор цветового акцента из пресетов или пользовательского градиента. accentButtons.forEach((button) => { button.addEventListener("click", () => { preferences.accent = button.dataset.accent; @@ -294,6 +304,20 @@ accentButtons.forEach((button) => { }); }); +// Изменение параметров своего градиента сразу переключает accent на custom. +function handleCustomAccentChange() { + 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", () => { preferences.showNumbers = showNumbersInput.checked; @@ -718,8 +742,21 @@ function applyPreferences(nextPreferences) { nextPreferences.view = defaultPreferences.view; } + if (!availableAccents.includes(nextPreferences.accent)) { + nextPreferences.accent = defaultPreferences.accent; + } + + nextPreferences.customAccentFrom = normalizeHexColor(nextPreferences.customAccentFrom) || defaultPreferences.customAccentFrom; + nextPreferences.customAccentTo = normalizeHexColor(nextPreferences.customAccentTo) || defaultPreferences.customAccentTo; + nextPreferences.customAccentAngle = clamp( + Number(nextPreferences.customAccentAngle) || defaultPreferences.customAccentAngle, + 0, + 360, + ); + document.documentElement.dataset.theme = nextPreferences.theme; document.documentElement.dataset.accent = nextPreferences.accent; + applyAccentVariables(nextPreferences); document.documentElement.style.setProperty( "--sidebar-width", `${clamp(Number(nextPreferences.sidebarWidth) || defaultPreferences.sidebarWidth, 260, 560)}px`, @@ -731,11 +768,87 @@ function applyPreferences(nextPreferences) { showNumbersInput.checked = Boolean(nextPreferences.showNumbers); showTreeTagsInput.checked = Boolean(nextPreferences.showTreeTags); autoCollapseSearchInput.checked = Boolean(nextPreferences.autoCollapseSearch); + customAccentFromInput.value = nextPreferences.customAccentFrom; + customAccentToInput.value = nextPreferences.customAccentTo; + customAccentAngleInput.value = String(nextPreferences.customAccentAngle); + customAccentAngleValue.textContent = `${nextPreferences.customAccentAngle}°`; + accentCustomPanel.hidden = nextPreferences.accent !== "custom"; + updateCustomAccentPreview(nextPreferences); setActiveButton(themeButtons, "theme", nextPreferences.theme); setActiveButton(viewButtons, "view", nextPreferences.view); setActiveButton(accentButtons, "accent", nextPreferences.accent); } +// Применяет 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) => { diff --git a/styles.css b/styles.css index cc1618e..50cc569 100644 --- a/styles.css +++ b/styles.css @@ -20,6 +20,7 @@ --accent: #8b5cf6; --accent-2: #22d3ee; --accent-rgb: 139, 92, 246; + --accent-angle: 135deg; --card-x: 50%; --card-y: 50%; --sidebar-width: 390px; @@ -273,7 +274,7 @@ html[data-show-tree-tags="false"] .tree-node small { border-radius: 4px; padding: 0 2px; color: white; - background: linear-gradient(135deg, var(--accent), var(--accent-2)); + background: linear-gradient(var(--accent-angle), var(--accent), var(--accent-2)); } /* ========================================================================== @@ -494,7 +495,7 @@ html[data-show-tree-tags="false"] .tree-node small { .toggle-row input:checked { border-color: rgba(var(--accent-rgb), 0.6); - background: linear-gradient(135deg, var(--accent), var(--accent-2)); + background: linear-gradient(var(--accent-angle), var(--accent), var(--accent-2)); } .toggle-row input:checked::before { @@ -556,12 +557,18 @@ html[data-show-tree-tags="false"] .tree-node small { } .segmented.is-active { - background: linear-gradient(135deg, var(--accent), var(--accent-2)); + background: linear-gradient(var(--accent-angle), var(--accent), var(--accent-2)); color: white; } .accent-group { - justify-content: space-between; + flex-wrap: wrap; + justify-content: flex-start; + row-gap: 8px; +} + +.accent-group > span { + width: 100%; } .accent-swatch { @@ -594,6 +601,130 @@ html[data-show-tree-tags="false"] .tree-node small { transform: scale(1.05); } +/* Кнопка своего градиента: pill, пунктир, подпись и превию через CSS-переменные. */ +.accent-swatch-custom { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + overflow: hidden; + width: auto; + min-width: 58px; + height: 32px; + padding: 0 10px; + border: 2px dashed rgba(255, 255, 255, 0.42); + box-shadow: none; + background: + linear-gradient(45deg, rgba(255, 255, 255, 0.08) 25%, transparent 25%, transparent 75%, rgba(255, 255, 255, 0.08) 75%), + linear-gradient(45deg, rgba(255, 255, 255, 0.08) 25%, transparent 25%, transparent 75%, rgba(255, 255, 255, 0.08) 75%); + background-size: 8px 8px; + background-position: 0 0, 4px 4px; +} + +html[data-theme="light"] .accent-swatch-custom { + border-color: rgba(15, 23, 42, 0.28); + background: + linear-gradient(45deg, rgba(15, 23, 42, 0.06) 25%, transparent 25%, transparent 75%, rgba(15, 23, 42, 0.06) 75%), + linear-gradient(45deg, rgba(15, 23, 42, 0.06) 25%, transparent 25%, transparent 75%, rgba(15, 23, 42, 0.06) 75%); + background-size: 8px 8px; + background-position: 0 0, 4px 4px; +} + +.accent-swatch-custom::before { + content: ""; + position: absolute; + inset: 2px; + border-radius: inherit; + background: linear-gradient( + var(--custom-accent-angle, 135deg), + var(--custom-accent-from, #8b5cf6), + var(--custom-accent-to, #22d3ee) + ); +} + +.accent-swatch-label { + position: relative; + z-index: 1; + color: white; + font-size: 0.72rem; + font-weight: 900; + letter-spacing: 0.04em; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.45); +} + +.accent-swatch-custom.is-active { + border-style: solid; + border-color: var(--text); + transform: scale(1.05); +} + +/* Панель настройки своего градиента появляется при data-accent="custom". */ +.accent-custom-panel { + display: grid; + gap: 8px; + padding: 10px; + border: 1px solid var(--border); + border-radius: 18px; + background: var(--surface); +} + +.accent-custom-panel[hidden] { + display: none; +} + +.accent-custom-title { + margin: 0; + color: var(--muted); + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.accent-custom-row { + display: grid; + grid-template-columns: 72px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 0.84rem; + font-weight: 700; +} + +.accent-custom-row input[type="range"] { + width: 100%; + accent-color: var(--accent); +} + +.accent-custom-row input[type="color"] { + width: 100%; + height: 34px; + padding: 2px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--panel); + cursor: pointer; +} + +.accent-custom-row:has(input[type="color"]) { + grid-template-columns: 72px 1fr; +} + +.accent-custom-row output { + min-width: 3.2rem; + color: var(--accent); + font-size: 0.82rem; + font-weight: 800; + text-align: right; +} + +.accent-custom-preview { + height: 36px; + border: 1px solid var(--border); + border-radius: 12px; + background: linear-gradient(var(--accent-angle), var(--accent), var(--accent-2)); +} + /* ========================================================================== 08. File-Tree Navigation -------------------------------------------------------------------------- @@ -1038,7 +1169,7 @@ html[data-show-tree-tags="false"] .tree-node small { .nav-card.is-hovered .arrow, .nav-card:focus-visible .arrow { transform: translateX(4px); - background: linear-gradient(135deg, var(--accent), var(--accent-2)); + background: linear-gradient(var(--accent-angle), var(--accent), var(--accent-2)); color: white; }