193 lines
		
	
	
		
			5.1 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
			
		
		
	
	
			193 lines
		
	
	
		
			5.1 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
| /*
 | |
|  * SPDX-FileCopyrightText: syuilo and misskey-project
 | |
|  * SPDX-License-Identifier: AGPL-3.0-only
 | |
|  */
 | |
| 
 | |
| import { ref } from 'vue';
 | |
| import tinycolor from 'tinycolor2';
 | |
| import lightTheme from '@@/themes/_light.json5';
 | |
| import darkTheme from '@@/themes/_dark.json5';
 | |
| import JSON5 from 'json5';
 | |
| import type { BundledTheme } from 'shiki/themes';
 | |
| import { deepClone } from '@/utility/clone.js';
 | |
| import { globalEvents } from '@/events.js';
 | |
| import { miLocalStorage } from '@/local-storage.js';
 | |
| import { addTheme, getThemes } from '@/theme-store.js';
 | |
| 
 | |
| export type Theme = {
 | |
| 	id: string;
 | |
| 	name: string;
 | |
| 	author: string;
 | |
| 	desc?: string;
 | |
| 	base?: 'dark' | 'light';
 | |
| 	props: Record<string, string>;
 | |
| 	codeHighlighter?: {
 | |
| 		base: BundledTheme;
 | |
| 		overrides?: Record<string, any>;
 | |
| 	} | {
 | |
| 		base: '_none_';
 | |
| 		overrides: Record<string, any>;
 | |
| 	};
 | |
| };
 | |
| 
 | |
| export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X'));
 | |
| 
 | |
| export const getBuiltinThemes = () => Promise.all(
 | |
| 	[
 | |
| 		'l-light',
 | |
| 		'l-coffee',
 | |
| 		'l-apricot',
 | |
| 		'l-rainy',
 | |
| 		'l-botanical',
 | |
| 		'l-vivid',
 | |
| 		'l-cherry',
 | |
| 		'l-sushi',
 | |
| 		'l-u0',
 | |
| 
 | |
| 		'd-dark',
 | |
| 		'd-persimmon',
 | |
| 		'd-astro',
 | |
| 		'd-future',
 | |
| 		'd-botanical',
 | |
| 		'd-green-lime',
 | |
| 		'd-green-orange',
 | |
| 		'd-cherry',
 | |
| 		'd-ice',
 | |
| 		'd-u0',
 | |
| 	].map(name => import(`@@/themes/${name}.json5`).then(({ default: _default }): Theme => _default)),
 | |
| );
 | |
| 
 | |
| export const getBuiltinThemesRef = () => {
 | |
| 	const builtinThemes = ref<Theme[]>([]);
 | |
| 	getBuiltinThemes().then(themes => builtinThemes.value = themes);
 | |
| 	return builtinThemes;
 | |
| };
 | |
| 
 | |
| let timeout: number | null = null;
 | |
| 
 | |
| export function applyTheme(theme: Theme, persist = true) {
 | |
| 	if (timeout) window.clearTimeout(timeout);
 | |
| 
 | |
| 	window.document.documentElement.classList.add('_themeChanging_');
 | |
| 
 | |
| 	timeout = window.setTimeout(() => {
 | |
| 		window.document.documentElement.classList.remove('_themeChanging_');
 | |
| 
 | |
| 		// 色計算など再度行えるようにクライアント全体に通知
 | |
| 		globalEvents.emit('themeChanged');
 | |
| 	}, 1000);
 | |
| 
 | |
| 	const colorScheme = theme.base === 'dark' ? 'dark' : 'light';
 | |
| 
 | |
| 	window.document.documentElement.dataset.colorScheme = colorScheme;
 | |
| 
 | |
| 	// Deep copy
 | |
| 	const _theme = deepClone(theme);
 | |
| 
 | |
| 	if (_theme.base) {
 | |
| 		const base = [lightTheme, darkTheme].find(x => x.id === _theme.base);
 | |
| 		if (base) _theme.props = Object.assign({}, base.props, _theme.props);
 | |
| 	}
 | |
| 
 | |
| 	const props = compile(_theme);
 | |
| 
 | |
| 	for (const tag of window.document.head.children) {
 | |
| 		if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') {
 | |
| 			tag.setAttribute('content', props['htmlThemeColor']);
 | |
| 			break;
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	for (const [k, v] of Object.entries(props)) {
 | |
| 		window.document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString());
 | |
| 	}
 | |
| 
 | |
| 	window.document.documentElement.style.setProperty('color-scheme', colorScheme);
 | |
| 
 | |
| 	if (persist) {
 | |
| 		miLocalStorage.setItem('theme', JSON.stringify(props));
 | |
| 		miLocalStorage.setItem('themeId', theme.id);
 | |
| 		miLocalStorage.setItem('colorScheme', colorScheme);
 | |
| 	}
 | |
| 
 | |
| 	// 色計算など再度行えるようにクライアント全体に通知
 | |
| 	globalEvents.emit('themeChanging');
 | |
| }
 | |
| 
 | |
| export function compile(theme: Theme): Record<string, string> {
 | |
| 	function getColor(val: string): tinycolor.Instance {
 | |
| 		if (val[0] === '@') { // ref (prop)
 | |
| 			return getColor(theme.props[val.substring(1)]);
 | |
| 		} else if (val[0] === '$') { // ref (const)
 | |
| 			return getColor(theme.props[val]);
 | |
| 		} else if (val[0] === ':') { // func
 | |
| 			const parts = val.split('<');
 | |
| 			const func = parts.shift().substring(1);
 | |
| 			const arg = parseFloat(parts.shift());
 | |
| 			const color = getColor(parts.join('<'));
 | |
| 
 | |
| 			switch (func) {
 | |
| 				case 'darken': return color.darken(arg);
 | |
| 				case 'lighten': return color.lighten(arg);
 | |
| 				case 'alpha': return color.setAlpha(arg);
 | |
| 				case 'hue': return color.spin(arg);
 | |
| 				case 'saturate': return color.saturate(arg);
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		// other case
 | |
| 		return tinycolor(val);
 | |
| 	}
 | |
| 
 | |
| 	const props = {};
 | |
| 
 | |
| 	for (const [k, v] of Object.entries(theme.props)) {
 | |
| 		if (k.startsWith('$')) continue; // ignore const
 | |
| 
 | |
| 		props[k] = v.startsWith('"') ? v.replace(/^"\s*/, '') : genValue(getColor(v));
 | |
| 	}
 | |
| 
 | |
| 	return props;
 | |
| }
 | |
| 
 | |
| function genValue(c: tinycolor.Instance): string {
 | |
| 	return c.toRgbString();
 | |
| }
 | |
| 
 | |
| export function validateTheme(theme: Record<string, any>): boolean {
 | |
| 	if (theme.id == null || typeof theme.id !== 'string') return false;
 | |
| 	if (theme.name == null || typeof theme.name !== 'string') return false;
 | |
| 	if (theme.base == null || !['light', 'dark'].includes(theme.base)) return false;
 | |
| 	if (theme.props == null || typeof theme.props !== 'object') return false;
 | |
| 	return true;
 | |
| }
 | |
| 
 | |
| export function parseThemeCode(code: string): Theme {
 | |
| 	let theme;
 | |
| 
 | |
| 	try {
 | |
| 		theme = JSON5.parse(code);
 | |
| 	} catch (err) {
 | |
| 		throw new Error('Failed to parse theme json');
 | |
| 	}
 | |
| 	if (!validateTheme(theme)) {
 | |
| 		throw new Error('This theme is invaild');
 | |
| 	}
 | |
| 	if (getThemes().some(t => t.id === theme.id)) {
 | |
| 		throw new Error('This theme is already installed');
 | |
| 	}
 | |
| 
 | |
| 	return theme;
 | |
| }
 | |
| 
 | |
| export function previewTheme(code: string): void {
 | |
| 	const theme = parseThemeCode(code);
 | |
| 	if (theme) applyTheme(theme, false);
 | |
| }
 | |
| 
 | |
| export async function installTheme(code: string): Promise<void> {
 | |
| 	const theme = parseThemeCode(code);
 | |
| 	if (!theme) return;
 | |
| 	await addTheme(theme);
 | |
| }
 |