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 { cloudAuth } from "@/seqta/utils/CloudAuth"; import { getApiBase } from "@/seqta/utils/DevApiBase"; import { updateAllColors } from "@/seqta/ui/colors/Manager"; import { clearCustomThemeAdaptiveCssVariables, setCustomThemeAdaptiveCssVariables, } from "@/seqta/ui/colors/customThemeAdaptiveBindings"; 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 }; 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 styleElement: HTMLStyleElement | null = null; private previewStyleElement: HTMLStyleElement | null = null; private previousImageVariableNames: string[] = []; private originalPreviewColor: string | null = null; private originalPreviewTheme: boolean | null = null; private imageUrlCache: Map = new Map(); private lastTransitionPoint: { x: number; y: number } = { x: 0, y: 0 }; private storeUpdateCheckRunning = false; private constructor() { console.debug("[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 { console.debug("[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 { 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 { console.debug("[ThemeManager] Disabling current theme"); try { if (!this.currentTheme) { console.debug("[ThemeManager] No theme to disable"); return; } await this.removeTheme(this.currentTheme); this.currentTheme = null; settingsState.selectedTheme = ""; console.debug("[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 { 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 { console.debug("[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") { console.debug( "[ThemeManager] Theme creator was open, clearing preview state", ); this.clearPreview(); localStorage.removeItem("themeCreatorOpen"); } if (settingsState.selectedTheme) { console.debug( "[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 { console.debug("[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 { console.debug("[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) { console.debug("[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) { console.debug("[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) { console.debug("[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 { console.debug("[ThemeManager] Applying theme:", theme.name); try { // Apply custom CSS if (theme.CustomCSS) { console.debug("[ThemeManager] Applying custom CSS"); this.applyCustomCSS(theme.CustomCSS); } // Apply custom images if (theme.CustomImages) { console.debug("[ThemeManager] Applying custom images"); theme.CustomImages.forEach((image) => { const imageUrl = URL.createObjectURL(image.blob); document.documentElement.style.setProperty( "--" + image.variableName, `url(${imageUrl})`, ); }); } // Apply theme settings 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 if (theme.selectedColor) { console.debug( "[ThemeManager] Restoring saved color:", theme.selectedColor, ); settingsState.selectedColor = theme.selectedColor; } else if (theme.defaultColour) { console.debug( "[ThemeManager] Using default color:", theme.defaultColour, ); settingsState.selectedColor = theme.defaultColour; } setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []); } 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 { console.debug("[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 { console.debug("[ThemeManager] Removing theme:", theme.name); try { // Remove custom CSS if (this.styleElement) { console.debug("[ThemeManager] Removing custom CSS"); this.styleElement.remove(); this.styleElement = null; } // Remove custom images if (theme.CustomImages) { console.debug("[ThemeManager] Removing custom images"); theme.CustomImages.forEach((image) => { const value = document.documentElement.style.getPropertyValue( "--" + image.variableName, ); if (value) { URL.revokeObjectURL(value.slice(4, -1)); // Remove url() wrapper } document.documentElement.style.removeProperty( "--" + image.variableName, ); }); } 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) { console.debug( "[ThemeManager] Restoring original color:", settingsState.originalSelectedColor, ); settingsState.selectedColor = settingsState.originalSelectedColor; } if (settingsState.originalDarkMode !== undefined) { console.debug( "[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); } } /** * Apply custom CSS to the document */ private applyCustomCSS(css: string): void { console.debug("[ThemeManager] Applying custom CSS"); try { if (!this.styleElement) { this.styleElement = document.createElement("style"); this.styleElement.id = "custom-theme"; document.head.appendChild(this.styleElement); } this.styleElement.textContent = css; } catch (error) { console.error("[ThemeManager] Error applying custom CSS:", error); } } /** * Get list of available themes */ public async getAvailableThemes(): Promise { console.debug("[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 { console.debug("[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 { console.debug("[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 { 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 { 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 { console.debug("[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"); } themeData = (await this.fetchFromUrl( downloadData.data.theme_json_url, )) 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 { console.debug("[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)"); } const fromStore = meta?.fromStore ?? false; const serverUpdatedAtSec = meta?.serverUpdatedAtSec; // Handle cover image (optional) let coverImageBlob = null; if (themeData.coverImage) { try { const strippedCoverImage = this.stripBase64Prefix( themeData.coverImage, ); coverImageBlob = this.base64ToBlob(strippedCoverImage); } 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: this.base64ToBlob(this.stripBase64Prefix(image.data)), }; } 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, }; 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 { 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 token = await cloudAuth.getStoredToken(); const res = (await browser.runtime.sendMessage({ type: "fetchThemes", token: token ?? undefined, })) as { success?: boolean; data?: { themes?: Array<{ id: string; updated_at?: number }> }; }; if (!res?.success || !res.data?.themes?.length) return; const serverById = new Map(); 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, }); console.log( "[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 { console.debug("[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 this.blobToBase64(image.blob), })), ); // Convert cover image to base64 const coverImageBase64 = coverImage ? await this.blobToBase64(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 { console.debug("[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 if (CustomCSS) { this.applyPreviewCSS(CustomCSS); } // Apply custom images const newImageVariableNames = CustomImages.map( (image) => image.variableName, ); // Remove old preview images this.previousImageVariableNames.forEach((variableName) => { if (!newImageVariableNames.includes(variableName)) { this.removeImageFromDocument(variableName); } }); // Apply new images CustomImages.forEach((image) => { const imageUrl = URL.createObjectURL(image.blob); document.documentElement.style.setProperty( `--${image.variableName}`, `url(${imageUrl})`, ); }); // Update previousImageVariableNames this.previousImageVariableNames = newImageVariableNames; // 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): Promise { console.debug("[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; } } // Apply CSS if changed if (theme.CustomCSS !== undefined) { this.applyPreviewCSS(theme.CustomCSS); } // Handle images if present if (theme.CustomImages) { const newImageVariableNames = theme.CustomImages.map( (image) => image.variableName, ); // Remove old preview images that are no longer present this.previousImageVariableNames.forEach((variableName) => { if (!newImageVariableNames.includes(variableName)) { this.removeImageFromDocument(variableName); // Clean up cached URL this.imageUrlCache.delete(variableName); } }); // Apply or update images theme.CustomImages.forEach((image) => { const existingUrl = this.imageUrlCache.get(image.variableName); if (!existingUrl) { // Only create new URL if one doesn't exist const imageUrl = URL.createObjectURL(image.blob); this.imageUrlCache.set(image.variableName, imageUrl); document.documentElement.style.setProperty( `--${image.variableName}`, `url(${imageUrl})`, ); } else { // Reuse existing URL document.documentElement.style.setProperty( `--${image.variableName}`, `url(${existingUrl})`, ); } }); this.previousImageVariableNames = newImageVariableNames; } // 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): void => { this.updatePreview(theme); }, 2, ); /** * Clear theme preview */ public clearPreview(): void { console.debug("[ThemeManager] Clearing theme preview"); try { // Remove preview images and revoke URLs this.previousImageVariableNames.forEach((variableName) => { this.removeImageFromDocument(variableName); }); // Clear all cached URLs this.imageUrlCache.forEach((url) => URL.revokeObjectURL(url)); this.imageUrlCache.clear(); this.previousImageVariableNames = []; // Remove preview CSS if (this.previewStyleElement) { this.previewStyleElement.remove(); this.previewStyleElement = null; } clearCustomThemeAdaptiveCssVariables(); // Restore original settings const storedColor = localStorage.getItem("originalPreviewColor"); if (storedColor) { settingsState.selectedColor = storedColor; localStorage.removeItem("originalPreviewColor"); } else if (this.originalPreviewColor !== null) { console.debug( "[ThemeManager] Restoring color from memory:", this.originalPreviewColor, ); settingsState.selectedColor = this.originalPreviewColor; console.debug( "[ThemeManager] Color after restore:", settingsState.selectedColor, ); } else { console.debug("[ThemeManager] No color to restore found"); } this.originalPreviewColor = null; if (this.originalPreviewTheme !== null) { console.debug( "[ThemeManager] Restoring dark mode:", this.originalPreviewTheme, ); settingsState.DarkMode = this.originalPreviewTheme; this.originalPreviewTheme = null; } void updateAllColors(); } catch (error) { console.error("[ThemeManager] Error clearing preview:", error); } } // Utility methods private stripBase64Prefix(base64String: string): string { if (!base64String) return ""; const prefixRegex = /^data:[^;]+;base64,/; try { return prefixRegex.test(base64String) ? base64String.replace(prefixRegex, "") : base64String; } catch (err) { console.error("[ThemeManager] Error stripping base64 prefix:", err); return ""; } } private base64ToBlob(base64: string): Blob { try { const byteString = atob(base64); const ab = new ArrayBuffer(byteString.length); const ia = new Uint8Array(ab); for (let i = 0; i < byteString.length; i++) { ia[i] = byteString.charCodeAt(i); } return new Blob([ab], { type: "image/png" }); } catch (err) { console.error("[ThemeManager] Error converting base64 to blob:", err); return new Blob(); } } private async blobToBase64(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => { const base64String = reader.result as string; const base64Data = base64String.split(",")[1]; resolve(base64Data); }; reader.onerror = reject; reader.readAsDataURL(blob); }); } 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); } } private removeImageFromDocument(variableName: string): void { try { const value = document.documentElement.style.getPropertyValue( "--" + variableName, ); if (value) { const url = this.imageUrlCache.get(variableName); if (url) { URL.revokeObjectURL(url); this.imageUrlCache.delete(variableName); } } document.documentElement.style.removeProperty("--" + variableName); } catch (err) { console.error("[ThemeManager] Error removing image from document:", err); } } private applyPreviewCSS(css: string): void { console.debug("[ThemeManager] Applying preview CSS"); try { if (!this.previewStyleElement) { this.previewStyleElement = document.createElement("style"); this.previewStyleElement.id = "custom-theme-preview"; document.head.appendChild(this.previewStyleElement); } this.previewStyleElement.textContent = css; } catch (error) { console.error("[ThemeManager] Error applying preview CSS:", error); } } }