diff --git a/package.json b/package.json index 175c8755..d175d5ff 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "betterseqtaplus", "version": "3.5.3", "type": "module", - "description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development add add heaps more features!", + "description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development and add heaps more features!", "browserslist": "> 0.5%, last 2 versions, not dead", "scripts": { "autoaudit": "npm audit && npm audit fix && npm run build", diff --git a/src/background.ts b/src/background.ts index e5959f81..6a897ffb 100644 --- a/src/background.ts +++ b/src/background.ts @@ -331,6 +331,7 @@ function getDefaultValues(): SettingsState { iconOnlySidebar: false, adaptiveThemeColour: false, adaptiveThemeGradient: false, + adaptiveThemeColourTransition: true, }; } diff --git a/src/interface/pages/settings/general.svelte b/src/interface/pages/settings/general.svelte index 09e6d1cd..1e6e414f 100644 --- a/src/interface/pages/settings/general.svelte +++ b/src/interface/pages/settings/general.svelte @@ -252,6 +252,18 @@ /> +
+
+

Smooth colour transition

+

Ease between class/subject colours when navigating instead of switching instantly

+
+
+ settingsState.adaptiveThemeColourTransition = isOn} + /> +
+
{/if} diff --git a/src/seqta/ui/colors/Manager.ts b/src/seqta/ui/colors/Manager.ts index 209c74ba..f035fe12 100644 --- a/src/seqta/ui/colors/Manager.ts +++ b/src/seqta/ui/colors/Manager.ts @@ -9,12 +9,80 @@ import { getAdaptiveColour } from "@/seqta/utils/adaptiveThemeColour"; import darkLogo from "@/resources/icons/betterseqta-light-full.png"; import lightLogo from "@/resources/icons/betterseqta-dark-full.png"; +const ADAPTIVE_THEME_TRANSITION_MS = 400; + +let colorTransitionRafId: number | null = null; +let lastInterpolatedHex: string | null = null; + // Helper functions const setCSSVar = (varName: any, value: any) => document.documentElement.style.setProperty(varName, value); const applyProperties = (props: any) => Object.entries(props).forEach(([key, value]) => setCSSVar(key, value)); +function easeInOutCubic(t: number): number { + return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; +} + +/** Best-effort parse of a single sRGB hex from a colour string (hex, rgb, or gradient). */ +function parseRepresentativeHex(s: string): string | null { + if (!s || !s.trim()) return null; + const trimmed = s.trim(); + try { + return Color(trimmed).hex(); + } catch { + // continue + } + if (trimmed.includes("gradient")) { + const regex = + /#[0-9a-fA-F]{6}|rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)|rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*[\d.]+\s*\)/gi; + const stops = trimmed.match(regex); + if (stops?.length) { + try { + return Color(stops[0]).hex(); + } catch { + // continue + } + } + } + const hexMatch = trimmed.match(/#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})\b/); + if (hexMatch) { + try { + return Color(hexMatch[0]).hex(); + } catch { + // continue + } + } + const rgbaMatch = trimmed.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i); + if (rgbaMatch) { + try { + return Color.rgb( + Number(rgbaMatch[1]), + Number(rgbaMatch[2]), + Number(rgbaMatch[3]), + ).hex(); + } catch { + // continue + } + } + return null; +} + +function getFromHex(): string | null { + const fromComputed = parseRepresentativeHex( + getComputedStyle(document.documentElement).getPropertyValue("--better-main").trim(), + ); + if (fromComputed) return fromComputed; + return lastInterpolatedHex; +} + +function cancelColorTransition() { + if (colorTransitionRafId !== null) { + cancelAnimationFrame(colorTransitionRafId); + colorTransitionRafId = null; + } +} + function applyColorsWith(selectedColor: string) { if (settingsState.transparencyEffects) { document.documentElement.classList.add("transparencyEffects"); @@ -89,15 +157,71 @@ export async function updateAllColors() { ? settingsState.selectedColor : "#007bff"; + let adaptiveHex: string | null = null; + if (settingsState.adaptiveThemeColour) { const adaptiveColor = await getAdaptiveColour(); if (adaptiveColor) { - effectiveColor = - settingsState.adaptiveThemeGradient - ? toSoftGradient(adaptiveColor) - : adaptiveColor; + adaptiveHex = adaptiveColor; + effectiveColor = settingsState.adaptiveThemeGradient + ? toSoftGradient(adaptiveColor) + : adaptiveColor; } } - applyColorsWith(effectiveColor); + const baseSelected = + settingsState.selectedColor !== "" ? settingsState.selectedColor : "#007bff"; + const toHex = + adaptiveHex ?? parseRepresentativeHex(baseSelected); + + const shouldAnimate = + settingsState.adaptiveThemeColour && + (settingsState.adaptiveThemeColourTransition ?? true) && + !!toHex; + + const applyImmediate = () => { + cancelColorTransition(); + applyColorsWith(effectiveColor); + if (toHex) lastInterpolatedHex = toHex; + }; + + if (!shouldAnimate) { + applyImmediate(); + return; + } + + const fromHex = getFromHex(); + + if (!fromHex || !toHex || fromHex === toHex) { + applyImmediate(); + return; + } + + const useSoftGradientOnFrames = + !!adaptiveHex && !!settingsState.adaptiveThemeGradient; + + cancelColorTransition(); + + const start = performance.now(); + + const step = (now: number) => { + const elapsed = now - start; + const t = Math.min(1, elapsed / ADAPTIVE_THEME_TRANSITION_MS); + const eased = easeInOutCubic(t); + const interpolatedHex = Color(fromHex).mix(Color(toHex), eased).hex(); + const display = useSoftGradientOnFrames + ? toSoftGradient(interpolatedHex) + : interpolatedHex; + applyColorsWith(display); + + if (t < 1) { + colorTransitionRafId = requestAnimationFrame(step); + } else { + colorTransitionRafId = null; + applyColorsWith(effectiveColor); + lastInterpolatedHex = toHex; + } + }; + + colorTransitionRafId = requestAnimationFrame(step); } diff --git a/src/seqta/utils/listeners/StorageChanges.ts b/src/seqta/utils/listeners/StorageChanges.ts index 15d495c9..7e178379 100644 --- a/src/seqta/utils/listeners/StorageChanges.ts +++ b/src/seqta/utils/listeners/StorageChanges.ts @@ -17,6 +17,9 @@ export class StorageChangeHandler { settingsState.register("selectedColor", () => void updateAllColors()); settingsState.register("adaptiveThemeColour", () => void updateAllColors()); settingsState.register("adaptiveThemeGradient", () => void updateAllColors()); + settingsState.register("adaptiveThemeColourTransition", () => + void updateAllColors(), + ); settingsState.register("DarkMode", this.handleDarkModeChange.bind(this)); settingsState.register("onoff", this.handleOnOffChange.bind(this)); settingsState.register("shortcuts", this.handleShortcutsChange.bind(this)); diff --git a/src/types/storage.ts b/src/types/storage.ts index dc683f2c..56dda410 100644 --- a/src/types/storage.ts +++ b/src/types/storage.ts @@ -43,6 +43,7 @@ export interface SettingsState { iconOnlySidebar?: boolean; adaptiveThemeColour?: boolean; adaptiveThemeGradient?: boolean; + adaptiveThemeColourTransition?: boolean; // depreciated keys animatedbk: boolean;