mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 03:34:40 +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 { slide } from 'svelte/transition';
|
||||||
import { fade } 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'
|
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
|
||||||
|
|
||||||
@@ -40,7 +43,9 @@
|
|||||||
coverImage: null,
|
coverImage: null,
|
||||||
isEditable: true,
|
isEditable: true,
|
||||||
hideThemeName: false,
|
hideThemeName: false,
|
||||||
forceDark: undefined
|
forceTheme: undefined,
|
||||||
|
forceDark: undefined,
|
||||||
|
adaptiveCssVariables: [],
|
||||||
})
|
})
|
||||||
let closedAccordions = $state<string[]>([])
|
let closedAccordions = $state<string[]>([])
|
||||||
let themeLoaded = $state(false);
|
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
|
themeLoaded = true
|
||||||
} else {
|
} else {
|
||||||
themeLoaded = true
|
themeLoaded = true
|
||||||
@@ -115,6 +126,13 @@
|
|||||||
}))
|
}))
|
||||||
themeClone.coverImage = theme.coverImage
|
themeClone.coverImage = theme.coverImage
|
||||||
|
|
||||||
|
if (shouldForceThemeAppearance(themeClone)) {
|
||||||
|
themeClone.forceTheme = true;
|
||||||
|
} else {
|
||||||
|
themeClone.forceTheme = false;
|
||||||
|
themeClone.forceDark = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
themeManager.clearPreview();
|
themeManager.clearPreview();
|
||||||
await themeManager.saveTheme(themeClone);
|
await themeManager.saveTheme(themeClone);
|
||||||
await themeManager.setTheme(themeClone.id);
|
await themeManager.setTheme(themeClone.id);
|
||||||
@@ -317,6 +335,27 @@
|
|||||||
|
|
||||||
<Divider />
|
<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 [
|
{#each [
|
||||||
{
|
{
|
||||||
type: 'switch',
|
type: 'switch',
|
||||||
@@ -332,21 +371,28 @@
|
|||||||
title: 'Force Theme',
|
title: 'Force Theme',
|
||||||
description: 'Force users to use either dark or light mode',
|
description: 'Force users to use either dark or light mode',
|
||||||
props: {
|
props: {
|
||||||
state: theme.forceDark !== undefined,
|
state: shouldForceThemeAppearance(theme),
|
||||||
onChange: (value: boolean) => theme = { ...theme, forceDark: value ? false : undefined }
|
onChange: (value: boolean) => {
|
||||||
|
if (value) {
|
||||||
|
theme = { ...theme, forceTheme: true, forceDark: false };
|
||||||
|
} else {
|
||||||
|
theme = { ...theme, forceTheme: false, forceDark: undefined };
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'conditional',
|
type: 'conditional',
|
||||||
props: {
|
props: {
|
||||||
condition: theme.forceDark !== undefined,
|
condition: shouldForceThemeAppearance(theme),
|
||||||
children: {
|
children: {
|
||||||
type: 'lightDarkToggle',
|
type: 'lightDarkToggle',
|
||||||
title: 'Mode',
|
title: 'Mode',
|
||||||
description: 'Choose whether to force light or dark mode',
|
description: 'Choose whether to force light or dark mode',
|
||||||
props: {
|
props: {
|
||||||
state: theme.forceDark === true,
|
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 localforage from "localforage";
|
||||||
import browser from "webextension-polyfill";
|
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 { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
import debounce from "@/seqta/utils/debounce";
|
import debounce from "@/seqta/utils/debounce";
|
||||||
|
import { updateAllColors } from "@/seqta/ui/colors/Manager";
|
||||||
|
import {
|
||||||
|
clearCustomThemeAdaptiveCssVariables,
|
||||||
|
setCustomThemeAdaptiveCssVariables,
|
||||||
|
} from "@/seqta/ui/colors/customThemeAdaptiveBindings";
|
||||||
|
|
||||||
type ThemeContent = {
|
type ThemeContent = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -14,6 +24,8 @@ type ThemeContent = {
|
|||||||
CustomCSS?: string;
|
CustomCSS?: string;
|
||||||
hideThemeName?: boolean;
|
hideThemeName?: boolean;
|
||||||
forceDark?: boolean;
|
forceDark?: boolean;
|
||||||
|
forceTheme?: boolean;
|
||||||
|
adaptiveCssVariables?: string[];
|
||||||
images: { id: string; variableName: string; data: string }[]; // data: base64
|
images: { id: string; variableName: string; data: string }[]; // data: base64
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -209,7 +221,7 @@ export class ThemeManager {
|
|||||||
console.debug("[ThemeManager] Storing original settings");
|
console.debug("[ThemeManager] Storing original settings");
|
||||||
settingsState.originalSelectedColor = settingsState.selectedColor;
|
settingsState.originalSelectedColor = settingsState.selectedColor;
|
||||||
|
|
||||||
if (theme.forceDark) {
|
if (shouldForceThemeAppearance(theme)) {
|
||||||
settingsState.originalDarkMode = settingsState.DarkMode;
|
settingsState.originalDarkMode = settingsState.DarkMode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -240,6 +252,7 @@ export class ThemeManager {
|
|||||||
this.currentTheme = theme;
|
this.currentTheme = theme;
|
||||||
settingsState.selectedTheme = themeId;
|
settingsState.selectedTheme = themeId;
|
||||||
}
|
}
|
||||||
|
void updateAllColors();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[ThemeManager] Error setting theme:", error);
|
console.error("[ThemeManager] Error setting theme:", error);
|
||||||
}
|
}
|
||||||
@@ -270,9 +283,10 @@ export class ThemeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply theme settings
|
// Apply theme settings
|
||||||
if (theme.forceDark !== undefined) {
|
if (shouldForceThemeAppearance(theme)) {
|
||||||
console.debug("[ThemeManager] Setting dark mode:", theme.forceDark);
|
const dark = getForcedDarkMode(theme);
|
||||||
settingsState.DarkMode = theme.forceDark;
|
console.debug("[ThemeManager] Setting dark mode:", dark);
|
||||||
|
settingsState.DarkMode = dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the stored selected color if available, otherwise use the default
|
// Use the stored selected color if available, otherwise use the default
|
||||||
@@ -289,6 +303,8 @@ export class ThemeManager {
|
|||||||
);
|
);
|
||||||
settingsState.selectedColor = theme.defaultColour;
|
settingsState.selectedColor = theme.defaultColour;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[ThemeManager] Error applying theme:", error);
|
console.error("[ThemeManager] Error applying theme:", error);
|
||||||
}
|
}
|
||||||
@@ -373,6 +389,7 @@ export class ThemeManager {
|
|||||||
if (clearSelectedTheme) {
|
if (clearSelectedTheme) {
|
||||||
settingsState.selectedTheme = "";
|
settingsState.selectedTheme = "";
|
||||||
}
|
}
|
||||||
|
clearCustomThemeAdaptiveCssVariables();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[ThemeManager] Error removing theme:", error);
|
console.error("[ThemeManager] Error removing theme:", error);
|
||||||
}
|
}
|
||||||
@@ -585,6 +602,13 @@ export class ThemeManager {
|
|||||||
isEditable: false,
|
isEditable: false,
|
||||||
hideThemeName: themeData.hideThemeName ?? false,
|
hideThemeName: themeData.hideThemeName ?? false,
|
||||||
forceDark: themeData.forceDark,
|
forceDark: themeData.forceDark,
|
||||||
|
forceTheme:
|
||||||
|
themeData.forceTheme !== undefined
|
||||||
|
? themeData.forceTheme
|
||||||
|
: themeData.forceDark !== undefined
|
||||||
|
? true
|
||||||
|
: undefined,
|
||||||
|
adaptiveCssVariables: themeData.adaptiveCssVariables,
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.saveTheme(theme);
|
await this.saveTheme(theme);
|
||||||
@@ -651,7 +675,7 @@ export class ThemeManager {
|
|||||||
public async previewTheme(theme: LoadedCustomTheme): Promise<void> {
|
public async previewTheme(theme: LoadedCustomTheme): Promise<void> {
|
||||||
console.debug("[ThemeManager] Previewing theme:", theme.name);
|
console.debug("[ThemeManager] Previewing theme:", theme.name);
|
||||||
try {
|
try {
|
||||||
const { CustomCSS, CustomImages, defaultColour, forceDark } = theme;
|
const { CustomCSS, CustomImages, defaultColour } = theme;
|
||||||
|
|
||||||
// Store original settings only if this is a new theme
|
// Store original settings only if this is a new theme
|
||||||
if (!theme.webURL) {
|
if (!theme.webURL) {
|
||||||
@@ -697,13 +721,16 @@ export class ThemeManager {
|
|||||||
this.previousImageVariableNames = newImageVariableNames;
|
this.previousImageVariableNames = newImageVariableNames;
|
||||||
|
|
||||||
// Apply theme settings
|
// Apply theme settings
|
||||||
if (forceDark !== undefined) {
|
if (shouldForceThemeAppearance(theme)) {
|
||||||
settingsState.DarkMode = forceDark;
|
settingsState.DarkMode = getForcedDarkMode(theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (defaultColour) {
|
if (defaultColour) {
|
||||||
settingsState.selectedColor = defaultColour;
|
settingsState.selectedColor = defaultColour;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
|
||||||
|
void updateAllColors();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[ThemeManager] Error previewing theme:", error);
|
console.error("[ThemeManager] Error previewing theme:", error);
|
||||||
}
|
}
|
||||||
@@ -769,15 +796,18 @@ export class ThemeManager {
|
|||||||
this.previousImageVariableNames = newImageVariableNames;
|
this.previousImageVariableNames = newImageVariableNames;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always apply dark mode setting
|
// Always apply dark mode setting when theme forces appearance
|
||||||
if (theme.forceDark !== undefined) {
|
if (shouldForceThemeAppearance(theme as CustomTheme)) {
|
||||||
settingsState.DarkMode = theme.forceDark;
|
settingsState.DarkMode = getForcedDarkMode(theme as CustomTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only apply color if this is a new theme
|
// Only apply color if this is a new theme
|
||||||
if (!theme.webURL && theme.defaultColour) {
|
if (!theme.webURL && theme.defaultColour) {
|
||||||
settingsState.selectedColor = theme.defaultColour;
|
settingsState.selectedColor = theme.defaultColour;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
|
||||||
|
void updateAllColors();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[ThemeManager] Error updating theme preview:", error);
|
console.error("[ThemeManager] Error updating theme preview:", error);
|
||||||
}
|
}
|
||||||
@@ -815,6 +845,8 @@ export class ThemeManager {
|
|||||||
this.previewStyleElement = null;
|
this.previewStyleElement = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearCustomThemeAdaptiveCssVariables();
|
||||||
|
|
||||||
// Restore original settings
|
// Restore original settings
|
||||||
const storedColor = localStorage.getItem("originalPreviewColor");
|
const storedColor = localStorage.getItem("originalPreviewColor");
|
||||||
|
|
||||||
@@ -844,6 +876,8 @@ export class ThemeManager {
|
|||||||
settingsState.DarkMode = this.originalPreviewTheme;
|
settingsState.DarkMode = this.originalPreviewTheme;
|
||||||
this.originalPreviewTheme = null;
|
this.originalPreviewTheme = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void updateAllColors();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[ThemeManager] Error clearing preview:", error);
|
console.error("[ThemeManager] Error clearing preview:", error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { lightenAndPaleColor } from "./lightenAndPaleColor";
|
|||||||
import ColorLuminance from "./ColorLuminance";
|
import ColorLuminance from "./ColorLuminance";
|
||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
import { getAdaptiveColour } from "@/seqta/utils/adaptiveThemeColour";
|
import { getAdaptiveColour } from "@/seqta/utils/adaptiveThemeColour";
|
||||||
|
import { getCustomThemeAdaptiveCssVariables } from "@/seqta/ui/colors/customThemeAdaptiveBindings";
|
||||||
|
|
||||||
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";
|
||||||
@@ -127,6 +128,12 @@ function applyColorsWith(selectedColor: string) {
|
|||||||
// Apply all the properties
|
// Apply all the properties
|
||||||
applyProperties({ ...commonProps, ...modeProps, ...dynamicProps });
|
applyProperties({ ...commonProps, ...modeProps, ...dynamicProps });
|
||||||
|
|
||||||
|
if (settingsState.selectedTheme) {
|
||||||
|
for (const name of getCustomThemeAdaptiveCssVariables()) {
|
||||||
|
setCSSVar(name, selectedColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let alliframes = document.getElementsByTagName("iframe");
|
let alliframes = document.getElementsByTagName("iframe");
|
||||||
|
|
||||||
for (let i = 0; i < alliframes.length; i++) {
|
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", () =>
|
settingsState.register("adaptiveThemeColourTransition", () =>
|
||||||
void updateAllColors(),
|
void updateAllColors(),
|
||||||
);
|
);
|
||||||
|
settingsState.register("selectedTheme", () => 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));
|
||||||
|
|||||||
@@ -12,7 +12,14 @@ export type CustomTheme = {
|
|||||||
hideThemeName: boolean;
|
hideThemeName: boolean;
|
||||||
webURL?: string;
|
webURL?: string;
|
||||||
selectedColor?: 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;
|
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 & {
|
export type LoadedCustomTheme = CustomTheme & {
|
||||||
@@ -37,3 +44,18 @@ export type ThemeList = {
|
|||||||
themes: CustomTheme[];
|
themes: CustomTheme[];
|
||||||
selectedTheme: string;
|
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