feat: Smooth change in colour, no hard cut (#415)

Added option smoothing on colour change so there is no hard cut made when switching subjects
This commit is contained in:
StroepWafel
2026-04-06 14:58:09 +09:30
committed by GitHub
parent 3c613f4938
commit a55cb84a69
6 changed files with 147 additions and 6 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "betterseqtaplus", "name": "betterseqtaplus",
"version": "3.5.3", "version": "3.5.3",
"type": "module", "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", "browserslist": "> 0.5%, last 2 versions, not dead",
"scripts": { "scripts": {
"autoaudit": "npm audit && npm audit fix && npm run build", "autoaudit": "npm audit && npm audit fix && npm run build",
+1
View File
@@ -331,6 +331,7 @@ function getDefaultValues(): SettingsState {
iconOnlySidebar: false, iconOnlySidebar: false,
adaptiveThemeColour: false, adaptiveThemeColour: false,
adaptiveThemeGradient: false, adaptiveThemeGradient: false,
adaptiveThemeColourTransition: true,
}; };
} }
@@ -252,6 +252,18 @@
/> />
</div> </div>
</div> </div>
<div class="flex justify-between items-center px-4 py-3 pl-6 border-t border-zinc-100 dark:border-zinc-700/50">
<div class="pr-4">
<h2 class="text-sm font-bold">Smooth colour transition</h2>
<p class="text-xs">Ease between class/subject colours when navigating instead of switching instantly</p>
</div>
<div>
<Switch
state={$settingsState.adaptiveThemeColourTransition ?? true}
onChange={(isOn: boolean) => settingsState.adaptiveThemeColourTransition = isOn}
/>
</div>
</div>
{/if} {/if}
</div> </div>
</div> </div>
+129 -5
View File
@@ -9,12 +9,80 @@ import { getAdaptiveColour } from "@/seqta/utils/adaptiveThemeColour";
import darkLogo from "@/resources/icons/betterseqta-light-full.png"; import darkLogo from "@/resources/icons/betterseqta-light-full.png";
import lightLogo from "@/resources/icons/betterseqta-dark-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 // Helper functions
const setCSSVar = (varName: any, value: any) => const setCSSVar = (varName: any, value: any) =>
document.documentElement.style.setProperty(varName, value); document.documentElement.style.setProperty(varName, value);
const applyProperties = (props: any) => const applyProperties = (props: any) =>
Object.entries(props).forEach(([key, value]) => setCSSVar(key, value)); 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) { function applyColorsWith(selectedColor: string) {
if (settingsState.transparencyEffects) { if (settingsState.transparencyEffects) {
document.documentElement.classList.add("transparencyEffects"); document.documentElement.classList.add("transparencyEffects");
@@ -89,15 +157,71 @@ export async function updateAllColors() {
? settingsState.selectedColor ? settingsState.selectedColor
: "#007bff"; : "#007bff";
let adaptiveHex: string | null = null;
if (settingsState.adaptiveThemeColour) { if (settingsState.adaptiveThemeColour) {
const adaptiveColor = await getAdaptiveColour(); const adaptiveColor = await getAdaptiveColour();
if (adaptiveColor) { if (adaptiveColor) {
effectiveColor = adaptiveHex = adaptiveColor;
settingsState.adaptiveThemeGradient effectiveColor = settingsState.adaptiveThemeGradient
? toSoftGradient(adaptiveColor) ? toSoftGradient(adaptiveColor)
: 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);
} }
@@ -17,6 +17,9 @@ export class StorageChangeHandler {
settingsState.register("selectedColor", () => void updateAllColors()); settingsState.register("selectedColor", () => void updateAllColors());
settingsState.register("adaptiveThemeColour", () => void updateAllColors()); settingsState.register("adaptiveThemeColour", () => void updateAllColors());
settingsState.register("adaptiveThemeGradient", () => void updateAllColors()); settingsState.register("adaptiveThemeGradient", () => void updateAllColors());
settingsState.register("adaptiveThemeColourTransition", () =>
void updateAllColors(),
);
settingsState.register("DarkMode", this.handleDarkModeChange.bind(this)); settingsState.register("DarkMode", this.handleDarkModeChange.bind(this));
settingsState.register("onoff", this.handleOnOffChange.bind(this)); settingsState.register("onoff", this.handleOnOffChange.bind(this));
settingsState.register("shortcuts", this.handleShortcutsChange.bind(this)); settingsState.register("shortcuts", this.handleShortcutsChange.bind(this));
+1
View File
@@ -43,6 +43,7 @@ export interface SettingsState {
iconOnlySidebar?: boolean; iconOnlySidebar?: boolean;
adaptiveThemeColour?: boolean; adaptiveThemeColour?: boolean;
adaptiveThemeGradient?: boolean; adaptiveThemeGradient?: boolean;
adaptiveThemeColourTransition?: boolean;
// depreciated keys // depreciated keys
animatedbk: boolean; animatedbk: boolean;