Spaces:
Runtime error
Runtime error
/** | |
* Custom theme management for Mistwarp | |
* Handles creation, storage, and management of user-defined themes including custom gradients and accents | |
*/ | |
import { Theme } from './index.js'; | |
import { persistTheme } from './themePersistance.js'; | |
const CUSTOM_THEMES_STORAGE_KEY = 'tw:custom-themes'; | |
const MAX_CUSTOM_THEMES = 50; // Reasonable limit to prevent storage issues | |
/** | |
* Utility functions for custom gradients and accent creation | |
*/ | |
class GradientUtils { | |
/** | |
* Create a linear gradient CSS string from color stops | |
* @param {Array} colorStops - Array of {color: string, position: number} objects | |
* @param {number} direction - Gradient direction in degrees (default: 90 for horizontal) | |
* @returns {string} CSS linear-gradient string | |
*/ | |
static createLinearGradient(colorStops, direction = 90) { | |
if (!Array.isArray(colorStops) || colorStops.length < 2) { | |
throw new Error('At least 2 color stops are required'); | |
} | |
const sortedStops = colorStops | |
.sort((a, b) => a.position - b.position) | |
.map(stop => `${stop.color} ${stop.position}%`); | |
return `linear-gradient(${direction}deg, ${sortedStops.join(', ')})`; | |
} | |
/** | |
* Convert hex color to RGBA | |
* @param {string} hex - Hex color string | |
* @param {number} opacity - Opacity value (0-1) | |
* @returns {string} RGBA color string | |
*/ | |
static hexToRgba(hex, opacity = 1) { | |
const cleanHex = hex.replace('#', ''); | |
const r = parseInt(cleanHex.substr(0, 2), 16); | |
const g = parseInt(cleanHex.substr(2, 2), 16); | |
const b = parseInt(cleanHex.substr(4, 2), 16); | |
return `rgba(${r}, ${g}, ${b}, ${opacity})`; | |
} | |
/** | |
* Convert hex to HSL | |
* @param {string} hex - Hex color string | |
* @returns {Object} HSL object {h, s, l} | |
*/ | |
static hexToHsl(hex) { | |
const r = parseInt(hex.slice(1, 3), 16) / 255; | |
const g = parseInt(hex.slice(3, 5), 16) / 255; | |
const b = parseInt(hex.slice(5, 7), 16) / 255; | |
const max = Math.max(r, g, b); | |
const min = Math.min(r, g, b); | |
let h, s, l = (max + min) / 2; | |
if (max === min) { | |
h = s = 0; // achromatic | |
} else { | |
const d = max - min; | |
s = l > 0.5 ? d / (2 - max - min) : d / (max + min); | |
switch (max) { | |
case r: h = (g - b) / d + (g < b ? 6 : 0); break; | |
case g: h = (b - r) / d + 2; break; | |
case b: h = (r - g) / d + 4; break; | |
} | |
h /= 6; | |
} | |
return { | |
h: Math.round(h * 360), | |
s: Math.round(s * 100), | |
l: Math.round(l * 100) | |
}; | |
} | |
/** | |
* Convert HSL to hex | |
* @param {number} h - Hue (0-360) | |
* @param {number} s - Saturation (0-100) | |
* @param {number} l - Lightness (0-100) | |
* @returns {string} Hex color string | |
*/ | |
static hslToHex(h, s, l) { | |
h /= 360; | |
s /= 100; | |
l /= 100; | |
const hue2rgb = (p, q, t) => { | |
if (t < 0) t += 1; | |
if (t > 1) t -= 1; | |
if (t < 1 / 6) return p + (q - p) * 6 * t; | |
if (t < 1 / 2) return q; | |
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; | |
return p; | |
}; | |
let r, g, b; | |
if (s === 0) { | |
r = g = b = l; // achromatic | |
} else { | |
const q = l < 0.5 ? l * (1 + s) : l + s - l * s; | |
const p = 2 * l - q; | |
r = hue2rgb(p, q, h + 1 / 3); | |
g = hue2rgb(p, q, h); | |
b = hue2rgb(p, q, h - 1 / 3); | |
} | |
const toHex = (c) => { | |
const hex = Math.round(c * 255).toString(16); | |
return hex.length === 1 ? '0' + hex : hex; | |
}; | |
return '#' + toHex(r) + toHex(g) + toHex(b); | |
} | |
/** | |
* Lighten a color by percentage | |
* @param {string} hex - Hex color string | |
* @param {number} percent - Percentage to lighten (0-100) | |
* @returns {string} Lightened hex color | |
*/ | |
static lightenColor(hex, percent) { | |
const hsl = this.hexToHsl(hex); | |
hsl.l = Math.min(100, hsl.l + percent); | |
return this.hslToHex(hsl.h, hsl.s, hsl.l); | |
} | |
/** | |
* Darken a color by percentage | |
* @param {string} hex - Hex color string | |
* @param {number} percent - Percentage to darken (0-100) | |
* @returns {string} Darkened hex color | |
*/ | |
static darkenColor(hex, percent) { | |
const hsl = this.hexToHsl(hex); | |
hsl.l = Math.max(0, hsl.l - percent); | |
return this.hslToHex(hsl.h, hsl.s, hsl.l); | |
} | |
/** | |
* Generate color variations for an accent theme | |
* @param {string} baseColor - Base hex color | |
* @returns {Object} Color variations | |
*/ | |
static generateColorVariations(baseColor) { | |
return { | |
primary: baseColor, | |
light: this.lightenColor(baseColor, 15), | |
lighter: this.lightenColor(baseColor, 30), | |
dark: this.darkenColor(baseColor, 15), | |
darker: this.darkenColor(baseColor, 30), | |
transparent: this.hexToRgba(baseColor, 0.35), | |
lightTransparent: this.hexToRgba(baseColor, 0.15), | |
mediumTransparent: this.hexToRgba(baseColor, 0.75) | |
}; | |
} | |
/** | |
* Generate accent theme colors from a primary color | |
* @param {string} primaryColor - Primary color (hex, rgb, hsl, etc.) | |
* @param {Object} options - Options for color generation | |
* @returns {Object} Generated accent colors | |
*/ | |
static generateAccentColors(primaryColor, options = {}) { | |
const variations = this.generateColorVariations(primaryColor); | |
return { | |
'motion-primary': variations.primary, | |
'motion-primary-transparent': variations.mediumTransparent, | |
'motion-tertiary': variations.dark, | |
'looks-secondary': variations.primary, | |
'looks-transparent': variations.transparent, | |
'looks-light-transparent': variations.lightTransparent, | |
'looks-secondary-dark': variations.dark, | |
'extensions-primary': variations.light, | |
'extensions-tertiary': variations.lighter, | |
'extensions-transparent': variations.transparent, | |
'extensions-light': variations.lighter, | |
'drop-highlight': variations.light | |
}; | |
} | |
/** | |
* Create a custom gradient accent theme | |
* @param {Array} colorStops - Gradient color stops | |
* @param {string} primaryColor - Primary accent color | |
* @param {Object} options - Additional options | |
* @returns {Object} Custom accent theme object | |
*/ | |
static createGradientAccent(colorStops, primaryColor, options = {}) { | |
const baseColors = this.generateAccentColors(primaryColor, options); | |
const gradient = this.createLinearGradient(colorStops, options.direction || 90); | |
// Create a version with reduced opacity for menu bar background | |
const gradientStopsWithOpacity = colorStops.map(stop => ({ | |
color: this.hexToRgba(stop.color, 0.8), | |
position: stop.position | |
})); | |
const gradientWithOpacity = this.createLinearGradient(gradientStopsWithOpacity, options.direction || 90); | |
return { | |
guiColors: { | |
...baseColors, | |
'menu-bar-background-image': gradientWithOpacity | |
}, | |
blockColors: { | |
checkboxActiveBackground: primaryColor, | |
checkboxActiveBorder: this.darkenColor(primaryColor, 10) | |
} | |
}; | |
} | |
/** | |
* Generate complementary colors for color harmonies | |
* @param {string} baseColor - Base hex color | |
* @returns {Object} Complementary color schemes | |
*/ | |
static generateColorHarmonies(baseColor) { | |
const hsl = this.hexToHsl(baseColor); | |
const complementary = this.hslToHex((hsl.h + 180) % 360, hsl.s, hsl.l); | |
const triadic1 = this.hslToHex((hsl.h + 120) % 360, hsl.s, hsl.l); | |
const triadic2 = this.hslToHex((hsl.h + 240) % 360, hsl.s, hsl.l); | |
const analogous1 = this.hslToHex((hsl.h + 30) % 360, hsl.s, hsl.l); | |
const analogous2 = this.hslToHex((hsl.h - 30 + 360) % 360, hsl.s, hsl.l); | |
return { | |
complementary: [baseColor, complementary], | |
triadic: [baseColor, triadic1, triadic2], | |
analogous: [baseColor, analogous1, analogous2], | |
monochromatic: [ | |
baseColor, | |
this.lightenColor(baseColor, 20), | |
this.darkenColor(baseColor, 20) | |
] | |
}; | |
} | |
/** | |
* Generate gradient presets | |
* @returns {Array} Array of gradient presets | |
*/ | |
static getGradientPresets() { | |
return [ | |
{ | |
name: 'Sunset', | |
colors: ['#ff6b6b', '#feca57', '#ff9ff3'], | |
direction: 90 | |
}, | |
{ | |
name: 'Ocean', | |
colors: ['#667eea', '#764ba2', '#6dd5ed'], | |
direction: 45 | |
}, | |
{ | |
name: 'Forest', | |
colors: ['#134e5e', '#71b280', '#a8e6cf'], | |
direction: 135 | |
}, | |
{ | |
name: 'Purple Rain', | |
colors: ['#667eea', '#764ba2', '#f093fb'], | |
direction: 90 | |
}, | |
{ | |
name: 'Fire', | |
colors: ['#ff416c', '#ff4b2b', '#ffb347'], | |
direction: 45 | |
}, | |
{ | |
name: 'Aurora', | |
colors: ['#00c9ff', '#92fe9d', '#a8e6cf'], | |
direction: 90 | |
}, | |
{ | |
name: 'Space', | |
colors: ['#2c3e50', '#4ca1af', '#c0392b'], | |
direction: 180 | |
}, | |
{ | |
name: 'Cherry', | |
colors: ['#eb3349', '#f45c43', '#ff8a80'], | |
direction: 90 | |
} | |
]; | |
} | |
/** | |
* Create gradient from preset | |
* @param {string} presetName - Name of the preset | |
* @param {string} primaryColor - Primary color override (optional) | |
* @returns {Object} Gradient accent theme | |
*/ | |
static createPresetGradient(presetName, primaryColor = null) { | |
const preset = this.getGradientPresets().find(p => p.name === presetName); | |
if (!preset) { | |
throw new Error(`Gradient preset "${presetName}" not found`); | |
} | |
const colorStops = preset.colors.map((color, index) => ({ | |
color: color, | |
position: (index / (preset.colors.length - 1)) * 100 | |
})); | |
const primary = primaryColor || preset.colors[0]; | |
return this.createGradientAccent(colorStops, primary, { | |
direction: preset.direction | |
}); | |
} | |
} | |
/** | |
* CustomTheme class extends Theme with additional metadata | |
*/ | |
class CustomTheme extends Theme { | |
constructor(name, description, accent, gui, blocks, menuBarAlign, wallpaper, fonts, author = 'User', isCustom = true) { | |
// If accent is an object (custom gradient), pass a default string to parent and store the custom accent separately | |
const accentKey = typeof accent === 'object' ? 'red' : accent; // Default to 'red' as fallback | |
super(accentKey, gui, blocks, menuBarAlign, wallpaper, fonts); | |
/** @readonly */ | |
this.name = name; | |
/** @readonly */ | |
this.description = description; | |
/** @readonly */ | |
this.author = author; | |
/** @readonly */ | |
this.isCustom = isCustom; | |
/** @readonly */ | |
this.createdAt = new Date().toISOString(); | |
/** @readonly */ | |
this.uuid = this.generateUUID(); | |
/** @readonly */ | |
this.customAccent = typeof accent === 'object' ? accent : null; | |
/** @readonly */ | |
this.originalAccent = accent; // Store the original accent for export | |
} | |
generateUUID() { | |
return 'custom-theme-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9); | |
} | |
/** | |
* Override getGuiColors to handle custom accent objects | |
* @returns {Object} GUI colors | |
*/ | |
getGuiColors() { | |
if (this.customAccent) { | |
// Use dynamic imports to avoid circular dependency issues | |
const defaultsDeep = require('lodash.defaultsdeep'); | |
// Get the base GUI colors directly without importing from index.js | |
let baseGuiColors = {}; | |
let guiLightColors = {}; | |
try { | |
// Import GUI theme modules directly | |
const guiLight = require('./gui/light.js'); | |
guiLightColors = guiLight.guiColors || {}; | |
// Try to get GUI colors for the current GUI theme | |
if (this.gui === 'dark') { | |
const guiDark = require('./gui/dark.js'); | |
baseGuiColors = guiDark.guiColors || {}; | |
} else if (this.gui === 'midnight') { | |
const guiMidnight = require('./gui/midnight.js'); | |
baseGuiColors = guiMidnight.guiColors || {}; | |
} else { | |
baseGuiColors = guiLightColors; | |
} | |
} catch (e) { | |
console.warn('Failed to load GUI theme modules:', e); | |
// Fallback to basic colors if import fails | |
baseGuiColors = { | |
'color-scheme': 'light', | |
'ui-primary': '#E5F0FF', | |
'text-primary': '#575E75' | |
}; | |
} | |
// For custom accents, use the custom accent object directly | |
const mergedColors = defaultsDeep( | |
{}, | |
this.customAccent.guiColors || {}, | |
baseGuiColors, | |
guiLightColors | |
); | |
return mergedColors; | |
} | |
// For standard accents, use the parent implementation | |
return super.getGuiColors(); | |
} | |
/** | |
* Override getBlockColors to handle custom accent objects | |
* @returns {Object} Block colors | |
*/ | |
getBlockColors() { | |
if (this.customAccent) { | |
// Use dynamic imports to avoid circular dependency issues | |
const defaultsDeep = require('lodash.defaultsdeep'); | |
// Get base block colors directly without importing from index.js | |
let baseGuiColors = {}; | |
let baseBlockColors = {}; | |
try { | |
// Import block theme modules directly | |
if (this.blocks === 'high-contrast') { | |
const blocksHighContrast = require('./blocks/high-contrast.js'); | |
baseBlockColors = blocksHighContrast.blockColors || {}; | |
} else if (this.blocks === 'dark') { | |
const blocksDark = require('./blocks/dark.js'); | |
baseBlockColors = blocksDark.blockColors || {}; | |
} else { | |
// Default to 'three' theme | |
const blocksThree = require('./blocks/three.js'); | |
baseBlockColors = blocksThree.blockColors || {}; | |
} | |
// Get GUI colors for block themes | |
if (this.gui === 'dark') { | |
const guiDark = require('./gui/dark.js'); | |
baseGuiColors = guiDark.blockColors || {}; | |
} else if (this.gui === 'midnight') { | |
const guiMidnight = require('./gui/midnight.js'); | |
baseGuiColors = guiMidnight.blockColors || {}; | |
} else { | |
const guiLight = require('./gui/light.js'); | |
baseGuiColors = guiLight.blockColors || {}; | |
} | |
} catch (e) { | |
console.warn('Failed to load block theme modules:', e); | |
// Fallback to basic block colors if import fails | |
baseBlockColors = { | |
motion: { primary: '#4C97FF', secondary: '#4280D7', tertiary: '#3373CC' }, | |
looks: { primary: '#9966FF', secondary: '#855CD6', tertiary: '#774DCB' } | |
}; | |
} | |
// For custom accents, use the custom accent object directly | |
const mergedColors = defaultsDeep( | |
{}, | |
this.customAccent.blockColors || {}, | |
baseGuiColors, | |
baseBlockColors | |
); | |
return mergedColors; | |
} | |
// For standard accents, use the parent implementation | |
return super.getBlockColors(); | |
} | |
/** | |
* Export theme to JSON format | |
* @returns {Object} Theme data | |
*/ | |
export() { | |
return { | |
uuid: this.uuid, | |
name: this.name, | |
description: this.description, | |
author: this.author, | |
createdAt: this.createdAt, | |
accent: this.originalAccent, // Use original accent (not the fallback string) | |
customAccent: this.customAccent, // Include custom accent data | |
gui: this.gui, | |
blocks: this.blocks, | |
menuBarAlign: this.menuBarAlign, | |
wallpaper: this.wallpaper, | |
fonts: this.fonts, | |
version: '1.0' | |
}; | |
} | |
/** | |
* Create CustomTheme from exported data | |
* @param {Object} data | |
* @returns {CustomTheme} | |
*/ | |
static import(data) { | |
if (!data || typeof data !== 'object') { | |
throw new Error('Invalid theme data'); | |
} | |
if (!data.name || !data.accent || !data.gui || !data.blocks) { | |
throw new Error('Missing required theme properties'); | |
} | |
// Use the original accent from the export data | |
const accentToUse = data.customAccent ? data.accent : data.accent; | |
const theme = new CustomTheme( | |
data.name, | |
data.description || '', | |
accentToUse, // This will be the gradient object for custom themes | |
data.gui, | |
data.blocks, | |
data.menuBarAlign, | |
data.wallpaper, | |
data.fonts, | |
data.author || 'Unknown' | |
); | |
// Preserve original UUID and creation date if available | |
if (data.uuid) { | |
Object.defineProperty(theme, 'uuid', { value: data.uuid, writable: false }); | |
} | |
if (data.createdAt) { | |
Object.defineProperty(theme, 'createdAt', { value: data.createdAt, writable: false }); | |
} | |
return theme; | |
} | |
} | |
/** | |
* CustomThemeManager handles storage and management of custom themes | |
*/ | |
class CustomThemeManager { | |
constructor() { | |
this.themes = new Map(); | |
this.loadCustomThemes(); | |
} | |
/** | |
* Load custom themes from localStorage | |
*/ | |
loadCustomThemes() { | |
try { | |
const stored = localStorage.getItem(CUSTOM_THEMES_STORAGE_KEY); | |
if (stored) { | |
const themesData = JSON.parse(stored); | |
for (const themeData of themesData) { | |
try { | |
const theme = CustomTheme.import(themeData); | |
this.themes.set(theme.uuid, theme); | |
} catch (e) { | |
console.warn('Failed to load custom theme:', e); | |
} | |
} | |
} | |
} catch (e) { | |
console.warn('Failed to load custom themes from storage:', e); | |
} | |
} | |
/** | |
* Save custom themes to localStorage | |
*/ | |
saveCustomThemes() { | |
try { | |
const themesData = Array.from(this.themes.values()).map(theme => theme.export()); | |
localStorage.setItem(CUSTOM_THEMES_STORAGE_KEY, JSON.stringify(themesData)); | |
} catch (e) { | |
console.warn('Failed to save custom themes to storage:', e); | |
throw new Error('Failed to save themes: ' + e.message); | |
} | |
} | |
/** | |
* Add a new custom theme | |
* @param {CustomTheme} theme | |
*/ | |
addTheme(theme) { | |
if (!(theme instanceof CustomTheme)) { | |
throw new Error('Theme must be an instance of CustomTheme'); | |
} | |
if (this.themes.size >= MAX_CUSTOM_THEMES) { | |
throw new Error(`Maximum number of custom themes (${MAX_CUSTOM_THEMES}) reached`); | |
} | |
// Check for duplicate names | |
for (const existingTheme of this.themes.values()) { | |
if (existingTheme.name === theme.name) { | |
throw new Error(`Theme with name "${theme.name}" already exists`); | |
} | |
} | |
this.themes.set(theme.uuid, theme); | |
this.saveCustomThemes(); | |
} | |
/** | |
* Remove a custom theme | |
* @param {string} uuid | |
*/ | |
removeTheme(uuid) { | |
if (this.themes.has(uuid)) { | |
this.themes.delete(uuid); | |
this.saveCustomThemes(); | |
return true; | |
} | |
return false; | |
} | |
/** | |
* Get a custom theme by UUID | |
* @param {string} uuid | |
* @returns {CustomTheme|null} | |
*/ | |
getTheme(uuid) { | |
return this.themes.get(uuid) || null; | |
} | |
/** | |
* Get all custom themes | |
* @returns {CustomTheme[]} | |
*/ | |
getAllThemes() { | |
return Array.from(this.themes.values()).sort((a, b) => a.name.localeCompare(b.name)); | |
} | |
/** | |
* Update an existing theme | |
* @param {string} uuid | |
* @param {Object} updates | |
*/ | |
updateTheme(uuid, updates) { | |
const existingTheme = this.themes.get(uuid); | |
if (!existingTheme) { | |
throw new Error('Theme not found'); | |
} | |
// Create new theme with updates | |
const updatedTheme = new CustomTheme( | |
updates.name || existingTheme.name, | |
updates.description !== undefined ? updates.description : existingTheme.description, | |
updates.accent || existingTheme.accent, | |
updates.gui || existingTheme.gui, | |
updates.blocks || existingTheme.blocks, | |
updates.menuBarAlign || existingTheme.menuBarAlign, | |
updates.wallpaper || existingTheme.wallpaper, | |
updates.fonts || existingTheme.fonts, | |
existingTheme.author | |
); | |
// Preserve original UUID and creation date | |
Object.defineProperty(updatedTheme, 'uuid', { value: uuid, writable: false }); | |
Object.defineProperty(updatedTheme, 'createdAt', { value: existingTheme.createdAt, writable: false }); | |
this.themes.set(uuid, updatedTheme); | |
this.saveCustomThemes(); | |
return updatedTheme; | |
} | |
/** | |
* Update gradient for an existing custom theme | |
* @param {string} uuid - Theme UUID | |
* @param {Array} colorStops - New gradient color stops | |
* @param {string} primaryColor - New primary accent color | |
* @param {Object} options - Additional options | |
* @returns {CustomTheme} Updated theme | |
*/ | |
updateThemeGradient(uuid, colorStops, primaryColor, options = {}) { | |
const existingTheme = this.themes.get(uuid); | |
if (!existingTheme) { | |
throw new Error('Theme not found'); | |
} | |
// Generate new gradient accent | |
const gradientAccent = GradientUtils.createGradientAccent(colorStops, primaryColor, options); | |
// Create updated theme with new gradient | |
const updatedTheme = new CustomTheme( | |
existingTheme.name, | |
existingTheme.description, | |
gradientAccent, // Updated gradient accent | |
existingTheme.gui, | |
existingTheme.blocks, | |
existingTheme.menuBarAlign, | |
existingTheme.wallpaper, | |
existingTheme.fonts, | |
existingTheme.author | |
); | |
// Preserve original UUID and creation date | |
Object.defineProperty(updatedTheme, 'uuid', { value: uuid, writable: false }); | |
Object.defineProperty(updatedTheme, 'createdAt', { value: existingTheme.createdAt, writable: false }); | |
this.themes.set(uuid, updatedTheme); | |
this.saveCustomThemes(); | |
return updatedTheme; | |
} | |
/** | |
* Check if a theme has a custom gradient | |
* @param {string} uuid - Theme UUID | |
* @returns {boolean} True if theme has custom gradient | |
*/ | |
hasCustomGradient(uuid) { | |
const theme = this.themes.get(uuid); | |
return theme && theme.customAccent && theme.customAccent.guiColors && | |
theme.customAccent.guiColors['menu-bar-background-image']; | |
} | |
/** | |
* Convert RGBA color to hex | |
* @param {string} rgba - RGBA color string like "rgba(255, 107, 107, 0.8)" | |
* @returns {string} Hex color string | |
*/ | |
rgbaToHex(rgba) { | |
const match = rgba.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*[\d.]+)?\)/); | |
if (!match) return rgba; // Return original if not RGBA format | |
const r = parseInt(match[1]); | |
const g = parseInt(match[2]); | |
const b = parseInt(match[3]); | |
const toHex = (n) => { | |
const hex = n.toString(16); | |
return hex.length === 1 ? '0' + hex : hex; | |
}; | |
return '#' + toHex(r) + toHex(g) + toHex(b); | |
} | |
/** | |
* Extract gradient information from a custom theme | |
* @param {string} uuid - Theme UUID | |
* @returns {Object|null} Gradient information or null if not a gradient theme | |
*/ | |
getThemeGradientInfo(uuid) { | |
const theme = this.themes.get(uuid); | |
if (!theme || !this.hasCustomGradient(uuid)) { | |
return null; | |
} | |
const gradientString = theme.customAccent.guiColors['menu-bar-background-image']; | |
// Try to parse the gradient string to extract colors and direction | |
const gradientMatch = gradientString.match(/linear-gradient\((\d+)deg,\s*(.+)\)/); | |
if (!gradientMatch) { | |
return null; | |
} | |
const direction = parseInt(gradientMatch[1]); | |
const colorString = gradientMatch[2]; | |
// Parse color stops using a more sophisticated approach | |
const colorStops = []; | |
// Split the color string by looking for patterns that start with rgba( or # | |
// This handles the comma issue within RGBA values | |
const stopPattern = /(?:rgba?\(\d+,\s*\d+,\s*\d+(?:,\s*[\d.]+)?\)\s*[\d.]*%?|#[a-fA-F0-9]{3,8}\s*[\d.]*%?)/g; | |
const stopMatches = colorString.match(stopPattern); | |
if (!stopMatches) { | |
return null; | |
} | |
stopMatches.forEach((stopString, index) => { | |
let color; | |
let position; | |
// Clean up the stop string | |
stopString = stopString.trim(); | |
// Check for RGBA format: rgba(255, 107, 107, 0.8) 50% | |
const rgbaMatch = stopString.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*[\d.]+)?\)\s*([\d.]+)%?/); | |
if (rgbaMatch) { | |
const [, r, g, b, pos] = rgbaMatch; | |
const rHex = parseInt(r).toString(16).padStart(2, '0'); | |
const gHex = parseInt(g).toString(16).padStart(2, '0'); | |
const bHex = parseInt(b).toString(16).padStart(2, '0'); | |
color = `#${rHex}${gHex}${bHex}`; | |
position = pos ? parseFloat(pos) : (index / (stopMatches.length - 1)) * 100; | |
} else { | |
// Check for hex format: #ff6b6b 50% | |
const hexMatch = stopString.match(/#([a-fA-F0-9]{3,8})/); | |
const posMatch = stopString.match(/([\d.]+)%/); | |
if (hexMatch) { | |
color = hexMatch[0]; | |
// Ensure 6-digit hex | |
if (color.length === 4) { | |
color = '#' + color[1] + color[1] + color[2] + color[2] + color[3] + color[3]; | |
} | |
} else { | |
color = '#000000'; // fallback | |
} | |
position = posMatch ? parseFloat(posMatch[1]) : (index / (stopMatches.length - 1)) * 100; | |
} | |
colorStops.push({ color, position }); | |
}); | |
// Sort color stops by position | |
colorStops.sort((a, b) => a.position - b.position); | |
// Try to extract primary color from accent colors | |
let primaryColor = '#ff6b6b'; // fallback | |
if (theme.customAccent.guiColors['motion-primary']) { | |
primaryColor = theme.customAccent.guiColors['motion-primary']; | |
} | |
// If we have color stops, use the first one as primary color | |
if (colorStops.length > 0) { | |
primaryColor = colorStops[0].color; | |
} | |
return { | |
colorStops, | |
direction, | |
primaryColor, | |
gradientString | |
}; | |
} | |
/** | |
* Export all custom themes | |
* @returns {Object} | |
*/ | |
exportAllThemes() { | |
const themes = this.getAllThemes().map(theme => theme.export()); | |
return { | |
version: '1.0', | |
exportedAt: new Date().toISOString(), | |
themes: themes, | |
count: themes.length | |
}; | |
} | |
/** | |
* Import themes from exported data | |
* @param {Object} data | |
* @param {boolean} overwrite Whether to overwrite existing themes with same name | |
* @returns {Object} Import results | |
*/ | |
importThemes(data, overwrite = false) { | |
if (!data || !Array.isArray(data.themes)) { | |
throw new Error('Invalid import data format'); | |
} | |
const results = { | |
imported: 0, | |
skipped: 0, | |
errors: [] | |
}; | |
for (const themeData of data.themes) { | |
try { | |
const theme = CustomTheme.import(themeData); | |
// Check for existing theme with same name | |
const existingTheme = Array.from(this.themes.values()) | |
.find(t => t.name === theme.name); | |
if (existingTheme && !overwrite) { | |
results.skipped++; | |
continue; | |
} | |
if (existingTheme && overwrite) { | |
this.removeTheme(existingTheme.uuid); | |
} | |
this.addTheme(theme); | |
results.imported++; | |
} catch (e) { | |
results.errors.push(`Failed to import theme "${themeData.name || 'Unknown'}": ${e.message}`); | |
} | |
} | |
return results; | |
} | |
/** | |
* Clear all custom themes | |
*/ | |
clearAllThemes() { | |
this.themes.clear(); | |
try { | |
localStorage.removeItem(CUSTOM_THEMES_STORAGE_KEY); | |
} catch (e) { | |
console.warn('Failed to clear custom themes storage:', e); | |
} | |
} | |
/** | |
* Create a custom theme from current theme | |
* @param {Theme} currentTheme | |
* @param {string} name | |
* @param {string} description | |
* @returns {CustomTheme} | |
*/ | |
createFromCurrentTheme(currentTheme, name, description = '') { | |
if (!name || typeof name !== 'string') { | |
throw new Error('Theme name is required'); | |
} | |
const customTheme = new CustomTheme( | |
name.trim(), | |
description.trim(), | |
currentTheme.accent, | |
currentTheme.gui, | |
currentTheme.blocks, | |
currentTheme.menuBarAlign, | |
currentTheme.wallpaper, | |
currentTheme.fonts | |
); | |
this.addTheme(customTheme); | |
return customTheme; | |
} | |
/** | |
* Create a custom gradient theme | |
* @param {string} name - Theme name | |
* @param {string} description - Theme description | |
* @param {Array} colorStops - Gradient color stops | |
* @param {string} primaryColor - Primary accent color | |
* @param {Object} options - Additional options | |
* @param {Theme} baseTheme - Base theme for GUI and block settings | |
* @returns {CustomTheme} | |
*/ | |
createGradientTheme(name, description, colorStops, primaryColor, options = {}, baseTheme) { | |
if (!name || typeof name !== 'string') { | |
throw new Error('Theme name is required'); | |
} | |
// Generate gradient accent | |
const gradientAccent = GradientUtils.createGradientAccent(colorStops, primaryColor, options); | |
const customTheme = new CustomTheme( | |
name.trim(), | |
description.trim(), | |
gradientAccent, // Custom gradient accent | |
baseTheme?.gui || 'light', | |
baseTheme?.blocks || 'three', | |
baseTheme?.menuBarAlign || 'left', | |
baseTheme?.wallpaper || null, | |
baseTheme?.fonts || null | |
); | |
this.addTheme(customTheme); | |
return customTheme; | |
} | |
} | |
// Singleton instance | |
const customThemeManager = new CustomThemeManager(); | |
export { | |
CustomTheme, | |
CustomThemeManager, | |
GradientUtils, | |
customThemeManager | |
}; | |