mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-05 19:24:39 +00:00
Adaptive custom themes (#416)
* feat: Smooth change in colour, no hard cut Added option smoothing on colour change so there is no hard cut made when switching subjects * feat: Themes can adapt to colour * quick fix to forced mode as well * [CodeFactor] Apply fixes --------- Co-authored-by: codefactor-io <support@codefactor.io>
This commit is contained in:
@@ -4,7 +4,10 @@
|
||||
import { slide } from 'svelte/transition';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import { type LoadedCustomTheme } from '@/types/CustomThemes'
|
||||
import {
|
||||
type LoadedCustomTheme,
|
||||
shouldForceThemeAppearance,
|
||||
} from '@/types/CustomThemes'
|
||||
|
||||
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
|
||||
|
||||
@@ -40,7 +43,9 @@
|
||||
coverImage: null,
|
||||
isEditable: true,
|
||||
hideThemeName: false,
|
||||
forceDark: undefined
|
||||
forceTheme: undefined,
|
||||
forceDark: undefined,
|
||||
adaptiveCssVariables: [],
|
||||
})
|
||||
let closedAccordions = $state<string[]>([])
|
||||
let themeLoaded = $state(false);
|
||||
@@ -80,7 +85,13 @@
|
||||
}))
|
||||
}
|
||||
|
||||
theme = loadedTheme
|
||||
theme = {
|
||||
...loadedTheme,
|
||||
adaptiveCssVariables: loadedTheme.adaptiveCssVariables ?? [],
|
||||
forceTheme:
|
||||
loadedTheme.forceTheme ??
|
||||
(loadedTheme.forceDark !== undefined ? true : undefined),
|
||||
}
|
||||
themeLoaded = true
|
||||
} else {
|
||||
themeLoaded = true
|
||||
@@ -115,6 +126,13 @@
|
||||
}))
|
||||
themeClone.coverImage = theme.coverImage
|
||||
|
||||
if (shouldForceThemeAppearance(themeClone)) {
|
||||
themeClone.forceTheme = true;
|
||||
} else {
|
||||
themeClone.forceTheme = false;
|
||||
themeClone.forceDark = undefined;
|
||||
}
|
||||
|
||||
themeManager.clearPreview();
|
||||
await themeManager.saveTheme(themeClone);
|
||||
await themeManager.setTheme(themeClone.id);
|
||||
@@ -317,6 +335,27 @@
|
||||
|
||||
<Divider />
|
||||
|
||||
<div class="py-3">
|
||||
<h2 class="text-sm font-bold">Adaptive CSS variables</h2>
|
||||
<p class="text-xs text-zinc-600 dark:text-zinc-400">
|
||||
One per line, each must start with <code class="text-xs">--</code>. These receive the same colour as the adaptive accent when "Adaptive theme colour" is enabled in general settings. Use them in Custom CSS, e.g. <code class="text-xs">border-color: var(--my-accent);</code>
|
||||
</p>
|
||||
<textarea
|
||||
placeholder="--my-accent --class-banner"
|
||||
value={theme.adaptiveCssVariables?.join('\n') ?? ''}
|
||||
oninput={(e) => {
|
||||
const lines = e.currentTarget.value
|
||||
.split(/\r?\n/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
theme = { ...theme, adaptiveCssVariables: lines };
|
||||
}}
|
||||
class="p-2 mt-2 w-full min-h-[5rem] font-mono text-sm rounded-lg border-0 transition dark:placeholder-zinc-400 bg-zinc-200 dark:bg-zinc-700 focus:outline-none focus:ring-1 focus:ring-zinc-100 dark:focus:ring-zinc-700 focus:bg-zinc-300/50 dark:focus:bg-zinc-600"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{#each [
|
||||
{
|
||||
type: 'switch',
|
||||
@@ -332,21 +371,28 @@
|
||||
title: 'Force Theme',
|
||||
description: 'Force users to use either dark or light mode',
|
||||
props: {
|
||||
state: theme.forceDark !== undefined,
|
||||
onChange: (value: boolean) => theme = { ...theme, forceDark: value ? false : undefined }
|
||||
state: shouldForceThemeAppearance(theme),
|
||||
onChange: (value: boolean) => {
|
||||
if (value) {
|
||||
theme = { ...theme, forceTheme: true, forceDark: false };
|
||||
} else {
|
||||
theme = { ...theme, forceTheme: false, forceDark: undefined };
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'conditional',
|
||||
props: {
|
||||
condition: theme.forceDark !== undefined,
|
||||
condition: shouldForceThemeAppearance(theme),
|
||||
children: {
|
||||
type: 'lightDarkToggle',
|
||||
title: 'Mode',
|
||||
description: 'Choose whether to force light or dark mode',
|
||||
props: {
|
||||
state: theme.forceDark === true,
|
||||
onChange: (value: boolean) => theme = { ...theme, forceDark: value }
|
||||
onChange: (value: boolean) =>
|
||||
(theme = { ...theme, forceDark: value, forceTheme: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import localforage from "localforage";
|
||||
import browser from "webextension-polyfill";
|
||||
import type { CustomTheme, LoadedCustomTheme } from "@/types/CustomThemes";
|
||||
import {
|
||||
type CustomTheme,
|
||||
getForcedDarkMode,
|
||||
type LoadedCustomTheme,
|
||||
shouldForceThemeAppearance,
|
||||
} from "@/types/CustomThemes";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import debounce from "@/seqta/utils/debounce";
|
||||
import { updateAllColors } from "@/seqta/ui/colors/Manager";
|
||||
import {
|
||||
clearCustomThemeAdaptiveCssVariables,
|
||||
setCustomThemeAdaptiveCssVariables,
|
||||
} from "@/seqta/ui/colors/customThemeAdaptiveBindings";
|
||||
|
||||
type ThemeContent = {
|
||||
id: string;
|
||||
@@ -14,6 +24,8 @@ type ThemeContent = {
|
||||
CustomCSS?: string;
|
||||
hideThemeName?: boolean;
|
||||
forceDark?: boolean;
|
||||
forceTheme?: boolean;
|
||||
adaptiveCssVariables?: string[];
|
||||
images: { id: string; variableName: string; data: string }[]; // data: base64
|
||||
};
|
||||
|
||||
@@ -209,7 +221,7 @@ export class ThemeManager {
|
||||
console.debug("[ThemeManager] Storing original settings");
|
||||
settingsState.originalSelectedColor = settingsState.selectedColor;
|
||||
|
||||
if (theme.forceDark) {
|
||||
if (shouldForceThemeAppearance(theme)) {
|
||||
settingsState.originalDarkMode = settingsState.DarkMode;
|
||||
}
|
||||
}
|
||||
@@ -240,6 +252,7 @@ export class ThemeManager {
|
||||
this.currentTheme = theme;
|
||||
settingsState.selectedTheme = themeId;
|
||||
}
|
||||
void updateAllColors();
|
||||
} catch (error) {
|
||||
console.error("[ThemeManager] Error setting theme:", error);
|
||||
}
|
||||
@@ -270,9 +283,10 @@ export class ThemeManager {
|
||||
}
|
||||
|
||||
// Apply theme settings
|
||||
if (theme.forceDark !== undefined) {
|
||||
console.debug("[ThemeManager] Setting dark mode:", theme.forceDark);
|
||||
settingsState.DarkMode = theme.forceDark;
|
||||
if (shouldForceThemeAppearance(theme)) {
|
||||
const dark = getForcedDarkMode(theme);
|
||||
console.debug("[ThemeManager] Setting dark mode:", dark);
|
||||
settingsState.DarkMode = dark;
|
||||
}
|
||||
|
||||
// Use the stored selected color if available, otherwise use the default
|
||||
@@ -289,6 +303,8 @@ export class ThemeManager {
|
||||
);
|
||||
settingsState.selectedColor = theme.defaultColour;
|
||||
}
|
||||
|
||||
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
|
||||
} catch (error) {
|
||||
console.error("[ThemeManager] Error applying theme:", error);
|
||||
}
|
||||
@@ -373,6 +389,7 @@ export class ThemeManager {
|
||||
if (clearSelectedTheme) {
|
||||
settingsState.selectedTheme = "";
|
||||
}
|
||||
clearCustomThemeAdaptiveCssVariables();
|
||||
} catch (error) {
|
||||
console.error("[ThemeManager] Error removing theme:", error);
|
||||
}
|
||||
@@ -585,6 +602,13 @@ export class ThemeManager {
|
||||
isEditable: false,
|
||||
hideThemeName: themeData.hideThemeName ?? false,
|
||||
forceDark: themeData.forceDark,
|
||||
forceTheme:
|
||||
themeData.forceTheme !== undefined
|
||||
? themeData.forceTheme
|
||||
: themeData.forceDark !== undefined
|
||||
? true
|
||||
: undefined,
|
||||
adaptiveCssVariables: themeData.adaptiveCssVariables,
|
||||
};
|
||||
|
||||
await this.saveTheme(theme);
|
||||
@@ -651,7 +675,7 @@ export class ThemeManager {
|
||||
public async previewTheme(theme: LoadedCustomTheme): Promise<void> {
|
||||
console.debug("[ThemeManager] Previewing theme:", theme.name);
|
||||
try {
|
||||
const { CustomCSS, CustomImages, defaultColour, forceDark } = theme;
|
||||
const { CustomCSS, CustomImages, defaultColour } = theme;
|
||||
|
||||
// Store original settings only if this is a new theme
|
||||
if (!theme.webURL) {
|
||||
@@ -697,13 +721,16 @@ export class ThemeManager {
|
||||
this.previousImageVariableNames = newImageVariableNames;
|
||||
|
||||
// Apply theme settings
|
||||
if (forceDark !== undefined) {
|
||||
settingsState.DarkMode = forceDark;
|
||||
if (shouldForceThemeAppearance(theme)) {
|
||||
settingsState.DarkMode = getForcedDarkMode(theme);
|
||||
}
|
||||
|
||||
if (defaultColour) {
|
||||
settingsState.selectedColor = defaultColour;
|
||||
}
|
||||
|
||||
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
|
||||
void updateAllColors();
|
||||
} catch (error) {
|
||||
console.error("[ThemeManager] Error previewing theme:", error);
|
||||
}
|
||||
@@ -769,15 +796,18 @@ export class ThemeManager {
|
||||
this.previousImageVariableNames = newImageVariableNames;
|
||||
}
|
||||
|
||||
// Always apply dark mode setting
|
||||
if (theme.forceDark !== undefined) {
|
||||
settingsState.DarkMode = theme.forceDark;
|
||||
// Always apply dark mode setting when theme forces appearance
|
||||
if (shouldForceThemeAppearance(theme as CustomTheme)) {
|
||||
settingsState.DarkMode = getForcedDarkMode(theme as CustomTheme);
|
||||
}
|
||||
|
||||
// Only apply color if this is a new theme
|
||||
if (!theme.webURL && theme.defaultColour) {
|
||||
settingsState.selectedColor = theme.defaultColour;
|
||||
}
|
||||
|
||||
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
|
||||
void updateAllColors();
|
||||
} catch (error) {
|
||||
console.error("[ThemeManager] Error updating theme preview:", error);
|
||||
}
|
||||
@@ -815,6 +845,8 @@ export class ThemeManager {
|
||||
this.previewStyleElement = null;
|
||||
}
|
||||
|
||||
clearCustomThemeAdaptiveCssVariables();
|
||||
|
||||
// Restore original settings
|
||||
const storedColor = localStorage.getItem("originalPreviewColor");
|
||||
|
||||
@@ -844,6 +876,8 @@ export class ThemeManager {
|
||||
settingsState.DarkMode = this.originalPreviewTheme;
|
||||
this.originalPreviewTheme = null;
|
||||
}
|
||||
|
||||
void updateAllColors();
|
||||
} catch (error) {
|
||||
console.error("[ThemeManager] Error clearing preview:", error);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { lightenAndPaleColor } from "./lightenAndPaleColor";
|
||||
import ColorLuminance from "./ColorLuminance";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import { getAdaptiveColour } from "@/seqta/utils/adaptiveThemeColour";
|
||||
import { getCustomThemeAdaptiveCssVariables } from "@/seqta/ui/colors/customThemeAdaptiveBindings";
|
||||
|
||||
import darkLogo from "@/resources/icons/betterseqta-light-full.png";
|
||||
import lightLogo from "@/resources/icons/betterseqta-dark-full.png";
|
||||
@@ -127,6 +128,12 @@ function applyColorsWith(selectedColor: string) {
|
||||
// Apply all the properties
|
||||
applyProperties({ ...commonProps, ...modeProps, ...dynamicProps });
|
||||
|
||||
if (settingsState.selectedTheme) {
|
||||
for (const name of getCustomThemeAdaptiveCssVariables()) {
|
||||
setCSSVar(name, selectedColor);
|
||||
}
|
||||
}
|
||||
|
||||
let alliframes = document.getElementsByTagName("iframe");
|
||||
|
||||
for (let i = 0; i < alliframes.length; i++) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/** Tracks which author-declared CSS variables mirror the effective accent; not persisted in settings storage. */
|
||||
|
||||
const VALID_CUSTOM_PROP = /^--[a-zA-Z0-9_-]{1,120}$/;
|
||||
|
||||
let boundNames: string[] = [];
|
||||
|
||||
export function normalizeAdaptiveCssVariableNames(
|
||||
names: string[] | undefined,
|
||||
): string[] {
|
||||
if (!names?.length) return [];
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of names) {
|
||||
const s = raw.trim();
|
||||
if (!VALID_CUSTOM_PROP.test(s) || seen.has(s)) continue;
|
||||
seen.add(s);
|
||||
out.push(s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function setCustomThemeAdaptiveCssVariables(
|
||||
names: string[] | undefined,
|
||||
): void {
|
||||
for (const n of boundNames) {
|
||||
document.documentElement.style.removeProperty(n);
|
||||
}
|
||||
boundNames = normalizeAdaptiveCssVariableNames(names);
|
||||
}
|
||||
|
||||
export function getCustomThemeAdaptiveCssVariables(): string[] {
|
||||
return boundNames;
|
||||
}
|
||||
|
||||
export function clearCustomThemeAdaptiveCssVariables(): void {
|
||||
for (const n of boundNames) {
|
||||
document.documentElement.style.removeProperty(n);
|
||||
}
|
||||
boundNames = [];
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export class StorageChangeHandler {
|
||||
settingsState.register("adaptiveThemeColourTransition", () =>
|
||||
void updateAllColors(),
|
||||
);
|
||||
settingsState.register("selectedTheme", () => void updateAllColors());
|
||||
settingsState.register("DarkMode", this.handleDarkModeChange.bind(this));
|
||||
settingsState.register("onoff", this.handleOnOffChange.bind(this));
|
||||
settingsState.register("shortcuts", this.handleShortcutsChange.bind(this));
|
||||
|
||||
@@ -12,7 +12,14 @@ export type CustomTheme = {
|
||||
hideThemeName: boolean;
|
||||
webURL?: string;
|
||||
selectedColor?: string;
|
||||
/**
|
||||
* When true, the theme forces light/dark via `forceDark` (`false` = light, `true` = dark).
|
||||
* When false/omitted, use legacy rule: `forceDark !== undefined` still means "force" for old JSON.
|
||||
*/
|
||||
forceTheme?: boolean;
|
||||
forceDark?: boolean;
|
||||
/** CSS custom property names (e.g. `--my-accent`) that receive the same value as `--better-main` when adaptive colours apply. */
|
||||
adaptiveCssVariables?: string[];
|
||||
};
|
||||
|
||||
export type LoadedCustomTheme = CustomTheme & {
|
||||
@@ -37,3 +44,18 @@ export type ThemeList = {
|
||||
themes: CustomTheme[];
|
||||
selectedTheme: string;
|
||||
};
|
||||
|
||||
/** Whether the theme forces appearance (light vs dark). */
|
||||
export function shouldForceThemeAppearance(theme: {
|
||||
forceTheme?: boolean;
|
||||
forceDark?: boolean;
|
||||
}): boolean {
|
||||
if (theme.forceTheme === true) return true;
|
||||
if (theme.forceTheme === false) return false;
|
||||
return theme.forceDark !== undefined;
|
||||
}
|
||||
|
||||
/** Resolved forced dark mode when forcing is active. */
|
||||
export function getForcedDarkMode(theme: { forceDark?: boolean }): boolean {
|
||||
return theme.forceDark === true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user