Files
BetterSEQTA-Plus/src/plugins/built-in/themes/theme-manager.ts
T
2026-07-19 18:08:52 +09:30

1060 lines
33 KiB
TypeScript

import localforage from "localforage";
import browser from "webextension-polyfill";
import {
type CustomTheme,
getForcedDarkMode,
type LoadedCustomTheme,
shouldForceThemeAppearance,
} from "@/types/CustomThemes";
import { BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY } from "@/seqta/utils/cloudSettingsSync";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import debounce from "@/seqta/utils/debounce";
import { themeUpdates } from "@/interface/hooks/ThemeUpdates";
import { getApiBase } from "@/seqta/utils/DevApiBase";
import { isAllowedFetchUrl } from "@/seqta/utils/allowedFetchUrl";
import { updateAllColors } from "@/seqta/ui/colors/Manager";
import {
clearCustomThemeAdaptiveCssVariables,
setCustomThemeAdaptiveCssVariables,
} from "@/seqta/ui/colors/customThemeAdaptiveBindings";
import {
clearThemeInPage,
syncThemeToPage,
type ThemePageSyncInput,
} from "@/seqta/utils/patchThemeImagesPageContext";
import { verboseDebug, verboseInfo } from "@/utils/verboseLog";
import {
clearThemeRuntime,
injectThemeDom,
runThemeScript,
type ThemeDomSpec,
type ThemeScriptSpec,
validateThemeDom,
validateThemeScript,
} from "./theme-runtime";
import {
base64ToBlob,
blobToBase64Data,
stripBase64Prefix,
} from "./themeImageUrl";
type ThemeContent = {
id: string;
name: string;
coverImage?: string; // base64, optional
description: string;
defaultColour?: string;
CanChangeColour?: boolean;
CustomCSS?: string;
hideThemeName?: boolean;
forceTheme?: boolean;
forceDark?: boolean;
adaptiveCssVariables?: string[];
images?: { id: string; variableName: string; data: string }[]; // data: base64
themeScript?: ThemeScriptSpec;
themeDom?: ThemeDomSpec;
};
export type InstallThemeMeta = {
fromStore: boolean;
/** Server list `updated_at` (Unix seconds); set when installing from store. */
serverUpdatedAtSec?: number;
forceTheme?: boolean;
adaptiveCssVariables?: string[];
images?: { id: string; variableName: string; data: string }[]; // data: base64
};
export class ThemeManager {
private static instance: ThemeManager;
private currentTheme: CustomTheme | null = null;
private lastSyncedImageKey: string | null = null;
private originalPreviewColor: string | null = null;
private originalPreviewTheme: boolean | null = null;
private lastTransitionPoint: { x: number; y: number } = { x: 0, y: 0 };
private storeUpdateCheckRunning = false;
private constructor() {
verboseDebug("[ThemeManager] Initializing...");
}
public static getInstance(): ThemeManager {
if (!ThemeManager.instance) {
ThemeManager.instance = new ThemeManager();
}
return ThemeManager.instance;
}
/**
* Get the currently active theme
*/
public getCurrentTheme(): CustomTheme | null {
return this.currentTheme;
}
/**
* Get a theme by ID from storage
*/
public async getTheme(themeId: string): Promise<CustomTheme | null> {
verboseDebug("[ThemeManager] Getting theme:", themeId);
try {
const theme = (await localforage.getItem(themeId)) as CustomTheme;
return theme;
} catch (error) {
console.error("[ThemeManager] Error getting theme:", error);
return null;
}
}
/**
* Get the ID of the currently selected theme
*/
public getSelectedThemeId(): string {
return settingsState.selectedTheme;
}
/**
* Update the last transition point based on a click or event
*/
public setTransitionPoint(x: number, y: number): void {
this.lastTransitionPoint = { x, y };
}
/**
* Apply a view transition animation
*/
private async applyViewTransition(callback: () => void): Promise<void> {
if (
!document.startViewTransition ||
!settingsState.animations ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
// Just run the callback without animation if transitions not supported
callback();
return;
}
// Use last known transition point or fallback to center
const x = this.lastTransitionPoint.x || window.innerWidth / 2;
const y = this.lastTransitionPoint.y || window.innerHeight / 2;
const right = window.innerWidth - x;
const bottom = window.innerHeight - y;
const maxRadius = Math.hypot(
Math.max(x, right),
Math.max(y, bottom),
);
await document.startViewTransition(() => {
callback();
}).ready;
try {
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${maxRadius}px at ${x}px ${y}px)`,
],
},
{
duration: 400,
easing: 'ease-in-out',
pseudoElement: '::view-transition-new(root)',
}
);
} catch (error) {
console.error("[ThemeManager] View Transition animation error:", error);
}
}
/**
* Disable the current theme without deleting it
*/
public async disableTheme(): Promise<void> {
verboseDebug("[ThemeManager] Disabling current theme");
try {
if (!this.currentTheme) {
verboseDebug("[ThemeManager] No theme to disable");
return;
}
await this.removeTheme(this.currentTheme);
this.currentTheme = null;
settingsState.selectedTheme = "";
verboseDebug("[ThemeManager] Theme disabled successfully");
} catch (error) {
console.error("[ThemeManager] Error disabling theme:", error);
}
}
/**
* After cloud restore, IndexedDB/theme storage is only reachable from page context (not MV3 SW).
* Background sets BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY; we fetch the store JSON here before setTheme().
* The resolved id matches cloud sync **`themeId` / `selectedTheme`**: it may be a standard theme uuid or a
* flavour (slave) variant id — **`downloadAndInstallStoreTheme`** is the same code path as the theme store installer.
*/
public async prepareThemeAfterCloudSync(): Promise<void> {
try {
const snap = await browser.storage.local.get(BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY);
const pending = snap[BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY];
if (pending === undefined) return;
await browser.storage.local.remove(BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY);
if (typeof pending !== "string") return;
const id = pending.trim();
if (!id) return;
const existing = (await localforage.getItem(id)) as CustomTheme | null;
if (existing) return;
await this.downloadAndInstallStoreTheme({ id, name: id });
} catch (e) {
console.warn("[ThemeManager] prepareThemeAfterCloudSync:", e);
}
}
/**
* Initialize the theme system and restore previous state
*/
public async initialize(): Promise<void> {
verboseDebug("[ThemeManager] Starting initialization");
try {
const neumorphicThemeId = "9a9786d1-b5fc-4a91-8c7a-f8bf7f7679ad";
const migrationCSS = "#title {\nbackground: transparent !important;\n}";
const theme = (await localforage.getItem(neumorphicThemeId)) as CustomTheme | null;
if (theme && theme.CustomCSS && !theme.CustomCSS.includes("#title {\nbackground: transparent !important;\n}")) {
theme.CustomCSS = theme.CustomCSS + "\n" + migrationCSS;
await localforage.setItem(neumorphicThemeId, theme);
}
const themeCreatorOpen = localStorage.getItem("themeCreatorOpen");
if (themeCreatorOpen === "true") {
verboseDebug(
"[ThemeManager] Theme creator was open, clearing preview state",
);
this.clearPreview();
localStorage.removeItem("themeCreatorOpen");
}
if (settingsState.selectedTheme) {
verboseDebug(
"[ThemeManager] Found selected theme, restoring:",
settingsState.selectedTheme,
);
await this.setTheme(settingsState.selectedTheme, false);
}
} catch (error) {
console.error("[ThemeManager] Error during initialization:", error);
} finally {
void this.checkStoreThemeUpdates();
}
}
/**
* Clean up theme system resources
*/
public async cleanup(): Promise<void> {
verboseDebug("[ThemeManager] Cleaning up resources");
try {
if (this.currentTheme) {
await this.removeTheme(this.currentTheme, false);
}
} catch (error) {
console.error("[ThemeManager] Error during cleanup:", error);
}
}
/**
* Set and apply a theme by ID
*/
public async setTheme(themeId: string, applyViewTransition: boolean = true): Promise<void> {
verboseDebug("[ThemeManager] Setting theme:", themeId);
try {
const theme = (await localforage.getItem(themeId)) as CustomTheme;
if (!theme) {
console.error("[ThemeManager] Theme not found:", themeId);
return;
}
// Store original settings before applying new theme
if (!settingsState.selectedTheme) {
verboseDebug("[ThemeManager] Storing original settings");
settingsState.originalSelectedColor = settingsState.selectedColor;
if (shouldForceThemeAppearance(theme)) {
settingsState.originalDarkMode = settingsState.DarkMode;
}
}
// Use view transition for the theme change
if (applyViewTransition) {
await this.applyViewTransition(async () => {
// Remove current theme if exists
if (this.currentTheme) {
verboseDebug("[ThemeManager] Removing current theme");
await this.removeThemeWithoutTransition(this.currentTheme);
}
// Apply new theme
await this.applyTheme(theme);
this.currentTheme = theme;
settingsState.selectedTheme = themeId;
});
} else {
// Remove current theme if exists
if (this.currentTheme) {
verboseDebug("[ThemeManager] Removing current theme");
await this.removeThemeWithoutTransition(this.currentTheme);
}
// Apply new theme
await this.applyTheme(theme);
this.currentTheme = theme;
settingsState.selectedTheme = themeId;
}
void updateAllColors();
} catch (error) {
console.error("[ThemeManager] Error setting theme:", error);
}
}
/**
* Apply theme components (CSS, images, settings)
*/
private async applyTheme(theme: CustomTheme): Promise<void> {
verboseDebug("[ThemeManager] Applying theme:", theme.name);
try {
// Run the theme script BEFORE injecting CustomCSS so any state the
// script publishes (e.g. `data-city-state` and `--city-sky-color` for
// Noir City) is already on <html> when the new CSS rules paint.
// Otherwise the CSS lands with var() unresolved and the page flashes
// its previous state before snapping to the right colour.
runThemeScript(theme.themeScript);
// Custom CSS + images must be applied in page context (Firefox).
verboseDebug("[ThemeManager] Applying theme styles in page context");
await syncThemeToPage({
customCss: theme.CustomCSS || "",
images: theme.CustomImages ?? [],
});
if (theme.CustomImages?.length) {
this.lastSyncedImageKey = this.imageSyncKey(theme.CustomImages);
} else {
this.lastSyncedImageKey = null;
}
// Apply theme settings
if (shouldForceThemeAppearance(theme)) {
const dark = getForcedDarkMode(theme);
verboseDebug("[ThemeManager] Setting dark mode:", dark);
settingsState.DarkMode = dark;
}
// Use the stored selected color if available, otherwise use the default
if (theme.selectedColor) {
verboseDebug(
"[ThemeManager] Restoring saved color:",
theme.selectedColor,
);
settingsState.selectedColor = theme.selectedColor;
} else if (theme.defaultColour) {
verboseDebug(
"[ThemeManager] Using default color:",
theme.defaultColour,
);
settingsState.selectedColor = theme.defaultColour;
}
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
injectThemeDom(theme.themeDom);
} catch (error) {
console.error("[ThemeManager] Error applying theme:", error);
}
}
/**
* Remove theme and restore original settings with view transition
*/
private async removeTheme(
theme: CustomTheme,
clearSelectedTheme: boolean = true,
): Promise<void> {
verboseDebug("[ThemeManager] Removing theme with transition:", theme.name);
try {
await this.applyViewTransition(async () => {
await this.removeThemeWithoutTransition(theme, clearSelectedTheme);
});
} catch (error) {
console.error("[ThemeManager] Error removing theme with transition:", error);
}
}
/**
* Remove theme without applying view transition animation
*/
private async removeThemeWithoutTransition(
theme: CustomTheme,
clearSelectedTheme: boolean = true,
): Promise<void> {
verboseDebug("[ThemeManager] Removing theme:", theme.name);
try {
clearThemeRuntime();
verboseDebug("[ThemeManager] Removing theme page styles");
clearThemeInPage();
this.lastSyncedImageKey = null;
if (this.currentTheme) {
// Store the current color with the theme before removing it
const selectedColor = settingsState.selectedColor;
const markUserEditedForColor =
this.currentTheme.userEdited !== true &&
this.currentTheme.installedFromStore !== false &&
selectedColor &&
this.currentTheme.defaultColour &&
selectedColor.trim().toLowerCase() !==
this.currentTheme.defaultColour.trim().toLowerCase();
await localforage.setItem(this.currentTheme.id, {
...this.currentTheme,
selectedColor,
...(markUserEditedForColor ? { userEdited: true } : {}),
});
}
// Restore original settings
if (settingsState.originalSelectedColor) {
verboseDebug(
"[ThemeManager] Restoring original color:",
settingsState.originalSelectedColor,
);
settingsState.selectedColor = settingsState.originalSelectedColor;
}
if (settingsState.originalDarkMode !== undefined) {
verboseDebug(
"[ThemeManager] Restoring original dark mode:",
settingsState.originalDarkMode,
);
settingsState.DarkMode = settingsState.originalDarkMode;
delete settingsState.originalDarkMode;
}
this.currentTheme = null;
if (clearSelectedTheme) {
settingsState.selectedTheme = "";
}
clearCustomThemeAdaptiveCssVariables();
} catch (error) {
console.error("[ThemeManager] Error removing theme:", error);
}
}
/**
* Stable key so preview updates can skip re-encoding image blobs when only CSS changed.
*/
private imageSyncKey(
images: Array<{ id: string; variableName: string; blob: Blob }>,
): string {
return images
.map((image) => `${image.id}:${image.variableName}:${image.blob.size}`)
.join("|");
}
/**
* Get list of available themes
*/
public async getAvailableThemes(): Promise<CustomTheme[]> {
verboseDebug("[ThemeManager] Getting available themes");
try {
const themeIds = (await localforage.getItem("customThemes")) as
| string[]
| null;
if (!themeIds) {
return [];
}
const themes = await Promise.all(
themeIds.map(async (id) => {
return (await localforage.getItem(id)) as CustomTheme;
}),
);
return themes.filter((theme) => theme !== null);
} catch (error) {
console.error("[ThemeManager] Error getting available themes:", error);
return [];
}
}
/**
* Save or update a theme
*/
public async saveTheme(theme: LoadedCustomTheme): Promise<void> {
verboseDebug("[ThemeManager] Saving theme:", theme.name);
try {
const existing = (await localforage.getItem(theme.id)) as CustomTheme | null;
let toSave = theme;
if (existing?.userEdited === true && theme.userEdited !== false) {
toSave = { ...theme, userEdited: true };
}
await localforage.setItem(toSave.id, toSave);
const themeIds = (await localforage.getItem("customThemes")) as
| string[]
| null;
if (themeIds) {
if (!themeIds.includes(toSave.id)) {
themeIds.push(toSave.id);
await localforage.setItem("customThemes", themeIds);
}
} else {
await localforage.setItem("customThemes", [toSave.id]);
}
} catch (error) {
console.error("[ThemeManager] Error saving theme:", error);
}
}
/**
* Delete a theme
*/
public async deleteTheme(themeId: string): Promise<void> {
verboseDebug("[ThemeManager] Deleting theme:", themeId);
try {
const theme = (await localforage.getItem(themeId)) as CustomTheme;
if (theme) {
if (this.currentTheme?.id === themeId) {
await this.removeTheme(theme);
}
await localforage.removeItem(themeId);
const themeIds = (await localforage.getItem("customThemes")) as
| string[]
| null;
if (themeIds) {
const updatedThemeIds = themeIds.filter((id) => id !== themeId);
await localforage.setItem("customThemes", updatedThemeIds);
}
}
} catch (error) {
console.error("[ThemeManager] Error deleting theme:", error);
}
}
/** Use a getter so dev-mode session-only base URL overrides take effect immediately. */
private get THEME_API_BASE(): string {
return `${getApiBase()}/api`;
}
private readonly GITHUB_THEMES_BASE = 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes';
/**
* Fetch JSON from a URL via background script (avoids CORS when running inside SEQTA page)
*/
private async fetchFromUrl(url: string): Promise<any> {
const result = (await browser.runtime.sendMessage({
type: 'fetchFromUrl',
url,
})) as { data?: unknown; error?: string };
if (result?.error) throw new Error(result.error);
return result?.data;
}
/**
* Download and install a theme from the store.
* Uses API first (increments download_count), falls back to GitHub if unreachable.
*/
public async downloadTheme(themeContent: {
id: string;
name: string;
description?: string;
coverImage?: string;
theme_json_url?: string;
updated_at?: number;
}): Promise<void> {
try {
await this.downloadAndInstallStoreTheme(themeContent);
} catch (error) {
console.error("[ThemeManager] Error downloading theme:", error);
}
}
/**
* Fetch theme.json from the store and install (throws on failure).
*/
private async downloadAndInstallStoreTheme(themeContent: {
id: string;
name: string;
description?: string;
coverImage?: string;
theme_json_url?: string;
updated_at?: number;
}): Promise<void> {
verboseDebug("[ThemeManager] Downloading theme:", themeContent.name);
if (!themeContent.id) {
throw new Error("Missing theme id");
}
let themeData: ThemeContent;
try {
const downloadData = (await this.fetchFromUrl(
`${this.THEME_API_BASE}/themes/${themeContent.id}/download`,
)) as { success?: boolean; data?: { theme_json_url: string } };
if (!downloadData?.success || !downloadData?.data?.theme_json_url) {
throw new Error("Failed to get theme download URL");
}
const themeJsonUrl = downloadData.data.theme_json_url;
if (!isAllowedFetchUrl(themeJsonUrl)) {
throw new Error("Theme download URL not allowed");
}
themeData = (await this.fetchFromUrl(
themeJsonUrl,
)) as ThemeContent;
} catch (apiError) {
console.warn("[ThemeManager] API failed, trying GitHub fallback:", apiError);
const githubUrl = `${this.GITHUB_THEMES_BASE}/${themeContent.id}/theme.json`;
themeData = (await this.fetchFromUrl(githubUrl)) as ThemeContent;
}
await this.installTheme(themeData, {
fromStore: true,
serverUpdatedAtSec: themeContent.updated_at,
});
}
/**
* Install a theme from theme data
*/
public async installTheme(
themeData: ThemeContent,
meta?: InstallThemeMeta,
): Promise<void> {
verboseDebug("[ThemeManager] Installing theme:", themeData.name);
try {
// Validate required fields
if (!themeData.id || !themeData.name) {
throw new Error("Theme is missing required fields (id or name)");
}
// Validate optional runtime hooks. Reject the install if either is
// malformed so a tampered theme cannot smuggle in unsafe values.
if (!validateThemeScript(themeData.themeScript)) {
throw new Error("Theme has invalid themeScript");
}
if (!validateThemeDom(themeData.themeDom)) {
throw new Error("Theme has invalid themeDom");
}
const fromStore = meta?.fromStore ?? false;
const serverUpdatedAtSec = meta?.serverUpdatedAtSec;
// Handle cover image (optional)
let coverImageBlob = null;
if (themeData.coverImage) {
try {
coverImageBlob = base64ToBlob(
stripBase64Prefix(themeData.coverImage),
"image/png",
);
} catch (e) {
console.warn("[ThemeManager] Failed to process cover image:", e);
// Continue without cover image
}
}
// Handle images (optional)
const images =
themeData.images
?.map((image) => {
try {
if (!image.id || !image.variableName || !image.data) {
console.warn("[ThemeManager] Skipping invalid image:", image);
return null;
}
return {
...image,
blob: base64ToBlob(stripBase64Prefix(image.data), "image/png"),
};
} catch (e) {
console.warn("[ThemeManager] Failed to process image:", e);
return null;
}
})
.filter((img) => img !== null) ?? [];
const theme: LoadedCustomTheme = {
id: themeData.id,
name: themeData.name,
description: themeData.description || "",
webURL: themeData.id,
coverImage: coverImageBlob,
CustomImages: images,
CustomCSS: themeData.CustomCSS || "",
defaultColour: themeData.defaultColour || "rgba(0, 123, 255, 1)",
CanChangeColour: themeData.CanChangeColour ?? true,
allowBackgrounds: true,
isEditable: false,
hideThemeName: themeData.hideThemeName ?? false,
forceDark: themeData.forceDark,
installedFromStore: fromStore,
userEdited: fromStore ? false : undefined,
storeSyncedAtSec:
fromStore && serverUpdatedAtSec != null
? serverUpdatedAtSec
: undefined,
forceTheme:
themeData.forceTheme !== undefined
? themeData.forceTheme
: themeData.forceDark !== undefined
? true
: undefined,
adaptiveCssVariables: themeData.adaptiveCssVariables,
themeScript: themeData.themeScript,
themeDom: themeData.themeDom,
};
await this.saveTheme(theme);
} catch (error) {
console.error("[ThemeManager] Error installing theme:", error);
throw error; // Re-throw to handle in UI
}
}
/**
* Compare installed store themes to GET /api/themes and refresh when the server is newer.
* Skips themes with userEdited: true (theme creator / popup save, or custom accent vs default).
*/
private static STORE_CHECK_KEY = "bsplus_lastStoreThemeCheck";
private static STORE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
public async checkStoreThemeUpdates(): Promise<void> {
if (this.storeUpdateCheckRunning) return;
const lastCheck = Number(localStorage.getItem(ThemeManager.STORE_CHECK_KEY) || 0);
if (Date.now() - lastCheck < ThemeManager.STORE_CHECK_INTERVAL_MS) return;
this.storeUpdateCheckRunning = true;
localStorage.setItem(ThemeManager.STORE_CHECK_KEY, String(Date.now()));
try {
const res = (await browser.runtime.sendMessage({
type: "fetchThemes",
})) as {
success?: boolean;
data?: { themes?: Array<{ id: string; updated_at?: number }> };
};
if (!res?.success || !res.data?.themes?.length) return;
const serverById = new Map<string, number>();
for (const t of res.data.themes) {
if (
typeof t.id === "string" &&
typeof t.updated_at === "number"
) {
serverById.set(t.id, t.updated_at);
}
}
if (serverById.size === 0) return;
const themeIds = (await localforage.getItem("customThemes")) as
| string[]
| null;
if (!themeIds?.length) return;
for (const id of themeIds) {
const theme = (await localforage.getItem(id)) as CustomTheme | null;
if (!theme || theme.userEdited === true) {
continue;
}
// File imports explicitly set installedFromStore: false — never auto-sync.
if (theme.installedFromStore === false) {
continue;
}
const serverUpdated = serverById.get(id);
if (serverUpdated == null) {
continue;
}
if (
theme.installedFromStore === true &&
theme.storeSyncedAtSec != null
) {
if (serverUpdated <= theme.storeSyncedAtSec) {
continue;
}
}
// Else: legacy installs from before store metadata (installedFromStore/storeSyncedAtSec
// unset) or incomplete rows — still listed on the server, so sync to latest once.
const wasSelected = settingsState.selectedTheme === id;
try {
if (wasSelected) {
await this.disableTheme();
}
await this.downloadAndInstallStoreTheme({
id: theme.id,
name: theme.name,
updated_at: serverUpdated,
});
verboseInfo(
"[ThemeManager] Theme auto-updated from store:",
theme.name,
);
if (wasSelected) {
await this.setTheme(id, false);
}
themeUpdates.triggerUpdate();
} catch (err) {
console.error("[ThemeManager] Store theme auto-update failed:", id, err);
if (wasSelected) {
try {
await this.setTheme(id, false);
} catch (restoreErr) {
console.error(
"[ThemeManager] Failed to restore theme after update error:",
restoreErr,
);
}
}
}
}
} catch (e) {
console.error("[ThemeManager] checkStoreThemeUpdates error:", e);
} finally {
this.storeUpdateCheckRunning = false;
}
}
/**
* Share a theme by exporting it
*/
public async shareTheme(themeId: string): Promise<void> {
verboseDebug("[ThemeManager] Sharing theme:", themeId);
try {
const theme = (await localforage.getItem(themeId)) as LoadedCustomTheme;
if (!theme) {
console.error("[ThemeManager] Theme not found");
return;
}
// Extract only the fields we want to share
const {
CustomImages = [],
coverImage,
webURL,
isEditable,
selectedColor,
allowBackgrounds,
installedFromStore,
storeSyncedAtSec,
userEdited,
...themeBasics
} = theme;
// Convert images to base64
const finalImages = await Promise.all(
CustomImages.map(async (image) => ({
id: image.id,
variableName: image.variableName,
data: await blobToBase64Data(image.blob),
})),
);
// Convert cover image to base64
const coverImageBase64 = coverImage
? await blobToBase64Data(coverImage)
: null;
// Create shareable theme data with only necessary fields
const shareableTheme = {
...themeBasics,
images: finalImages,
coverImage: coverImageBase64,
};
// Save theme file
this.saveThemeFile(shareableTheme, theme.name || "Unnamed_Theme");
} catch (error) {
console.error("[ThemeManager] Error sharing theme:", error);
}
}
/**
* Preview a theme without applying it
*/
public async previewTheme(theme: LoadedCustomTheme): Promise<void> {
verboseDebug("[ThemeManager] Previewing theme:", theme.name);
try {
const { CustomCSS, CustomImages, defaultColour } = theme;
// Store original settings only if this is a new theme
if (!theme.webURL) {
if (this.originalPreviewColor === null) {
this.originalPreviewColor = settingsState.selectedColor;
localStorage.setItem(
"originalPreviewColor",
settingsState.selectedColor,
);
}
if (this.originalPreviewTheme === null) {
this.originalPreviewTheme = settingsState.DarkMode;
}
}
// Apply custom CSS + images in page context (preview stylesheet)
await syncThemeToPage({
previewCss: CustomCSS,
images: CustomImages,
});
this.lastSyncedImageKey = this.imageSyncKey(CustomImages);
// Apply theme settings
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);
}
}
/**
* Update the preview of a theme in real-time (for theme creator)
*/
public async updatePreview(theme: Partial<LoadedCustomTheme>): Promise<void> {
verboseDebug("[ThemeManager] Updating theme preview");
try {
// Only store original settings if this is a new theme (not editing)
// We can tell it's a new theme if it has no webURL (which is set when a theme is saved/loaded)
if (!theme.webURL) {
if (this.originalPreviewColor === null) {
this.originalPreviewColor = settingsState.selectedColor;
}
if (this.originalPreviewTheme === null) {
this.originalPreviewTheme = settingsState.DarkMode;
}
}
const syncInput: ThemePageSyncInput = {};
if (theme.CustomCSS !== undefined) {
syncInput.previewCss = theme.CustomCSS;
}
if (theme.CustomImages) {
const imageKey = this.imageSyncKey(theme.CustomImages);
if (imageKey !== this.lastSyncedImageKey) {
syncInput.images = theme.CustomImages;
this.lastSyncedImageKey = imageKey;
}
}
if (Object.keys(syncInput).length > 0) {
await syncThemeToPage(syncInput);
}
// 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);
}
}
/**
* Update the preview of a theme (debounced)
* @param theme - The theme to update the preview of
*/
public updatePreviewDebounced = debounce(
(theme: Partial<LoadedCustomTheme>): void => {
this.updatePreview(theme);
},
2,
);
/**
* Clear theme preview
*/
public clearPreview(): void {
verboseDebug("[ThemeManager] Clearing theme preview");
try {
void syncThemeToPage({ clearPreview: true, images: [] });
this.lastSyncedImageKey = null;
clearCustomThemeAdaptiveCssVariables();
// Restore original settings
const storedColor = localStorage.getItem("originalPreviewColor");
if (storedColor) {
settingsState.selectedColor = storedColor;
localStorage.removeItem("originalPreviewColor");
} else if (this.originalPreviewColor !== null) {
verboseDebug(
"[ThemeManager] Restoring color from memory:",
this.originalPreviewColor,
);
settingsState.selectedColor = this.originalPreviewColor;
verboseDebug(
"[ThemeManager] Color after restore:",
settingsState.selectedColor,
);
} else {
verboseDebug("[ThemeManager] No color to restore found");
}
this.originalPreviewColor = null;
if (this.originalPreviewTheme !== null) {
verboseDebug(
"[ThemeManager] Restoring dark mode:",
this.originalPreviewTheme,
);
settingsState.DarkMode = this.originalPreviewTheme;
this.originalPreviewTheme = null;
}
void updateAllColors();
} catch (error) {
console.error("[ThemeManager] Error clearing preview:", error);
}
}
private saveThemeFile(data: object, fileName: string): void {
try {
const fileData = JSON.stringify(data, null, 2);
const blob = new Blob([fileData], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${fileName}.theme.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
console.error("[ThemeManager] Error saving theme file:", err);
}
}
}