1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-05-14 11:05:47 +02:00
Files
misskey/packages/frontend/src/theme.ts
かっこかり b528ff9c59 enhance(frontend): テーマの適用管理を改善 (#17376)
* wip

* add test

* use themeManager.currentCompiledTheme for obtaining theme variables / reduce getComputedStyle usage

* fix

* fix: better error handling on theme installation

* Update Changelog

* chore: remove frontend-shared builds as it is currently working as a stub package

* fix: broken lockfile

* fix

* fix lint

* fix
2026-05-07 11:42:45 +09:00

240 lines
7.6 KiB
TypeScript

/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
// TODO: (可能な部分を)sharedに抽出して frontend-embed と共通化
import { ref, nextTick } from 'vue';
import { EventEmitter } from 'eventemitter3';
import lightTheme from '@@/themes/_light.json5';
import darkTheme from '@@/themes/_dark.json5';
import { version } from '@@/js/config.js';
import { getBuiltinThemes, parseThemeCode, themeProps, compile } from '@@/js/theme.js';
import type { Theme, CompiledTheme } from '@@/js/theme.js';
import { deepClone } from '@/utility/clone.js';
import { miLocalStorage } from '@/local-storage.js';
import { $i } from '@/i.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { prefer } from '@/preferences.js';
type ThemeManagerEvents = {
'themeChanging': () => void;
'themeChanged': () => void;
'previewStateChanged': (isPreview: boolean) => void;
'requestUpdateThemeCache': (theme: Theme, compiled: CompiledTheme) => void;
};
class ThemeManager extends EventEmitter<ThemeManagerEvents> {
/** 現在常用しているテーマ */
private _theme: Theme | null = null;
get theme() { return this._theme; }
private _compiledTheme: CompiledTheme | null = null;
get compiledTheme() { return this._compiledTheme; }
/** 現在適用中のテーマ */
private _currentTheme: Theme | null = null;
get currentTheme() { return this._currentTheme; }
get currentThemeId() { return this._currentTheme?.id; }
private _currentCompiledTheme: CompiledTheme | null = null;
get currentCompiledTheme() { return this._currentCompiledTheme; }
/** プレビュー中かどうか */
private _isPreviewMode = false;
get isPreviewMode() { return this._isPreviewMode; }
set isPreviewMode(value: boolean) {
if (this._isPreviewMode !== value) {
this._isPreviewMode = value;
this.emit('previewStateChanged', value);
}
}
constructor() {
super();
}
/** テーマを更新し、同時に適用します。 */
public updateTheme(newTheme: Theme) {
if (newTheme.id === this.theme?.id && version === miLocalStorage.getItem('themeCachedVersion')) return; // 変更なし
this.isPreviewMode = false;
// テーマを更新
this._theme = deepClone(newTheme);
const compiled = this.compile(newTheme);
this._compiledTheme = compiled;
// 適用中のテーマも更新
this._currentTheme = deepClone(this.theme);
this._currentCompiledTheme = deepClone(compiled);
this.applyTheme();
}
/** プレビュー用のテーマを適用します。 */
public previewTheme(theme: Theme) {
this.isPreviewMode = true;
// 適用中のテーマを更新
this._currentTheme = deepClone(theme);
this._currentCompiledTheme = this.compile(theme);
this.applyTheme();
}
/** プレビュー状態を解除し、適用中のテーマを常用しているテーマに戻します。 */
public clearPreview() {
this.isPreviewMode = false;
// 適用中のテーマを常用しているテーマに戻す
this._currentTheme = deepClone(this.theme);
this._currentCompiledTheme = deepClone(this.compiledTheme);
this.applyTheme();
}
/** 通常のテーマのコンパイルに加え、ベースとなるテーマの値を解決し代入します。 */
private compile(theme: Theme) {
const _theme = deepClone(theme);
if (_theme.base != null) {
const base = [lightTheme, darkTheme].find(x => x.id === _theme.base);
if (base) _theme.props = Object.assign({}, base.props, _theme.props);
}
return compile(_theme);
}
/** currentThemeを適用します。 */
private applyTheme() {
if (this.currentTheme == null || this.currentCompiledTheme == null) return;
// visibilityStateがhiddenな状態でstartViewTransitionするとブラウザによってはエラーになる
// 通常hiddenな時に呼ばれることはないが、iOSのPWAだとアプリ切り替え時に(何故か)hiddenな状態で(何故か)一瞬デバイスのダークモード判定が変わりapplyThemeが呼ばれる場合がある
if (window.document.startViewTransition != null && window.document.visibilityState === 'visible') {
window.document.documentElement.classList.add('_themeChanging_');
try {
window.document.startViewTransition(async () => {
this.updateAttributes();
await nextTick();
}).finished.then(() => {
window.document.documentElement.classList.remove('_themeChanging_');
this.emit('themeChanged');
});
} catch (err) {
// 様々な理由により startViewTransition は失敗することがある
// ref. https://github.com/misskey-dev/misskey/issues/16562
// FIXME: viewTransitonエラーはtry~catch貫通してそうな気配がする
console.error(err);
window.document.documentElement.classList.remove('_themeChanging_');
this.updateAttributes();
this.emit('themeChanged');
}
} else {
this.updateAttributes();
this.emit('themeChanged');
}
if (!this.isPreviewMode) {
this.emit('requestUpdateThemeCache', this.currentTheme, this.currentCompiledTheme);
}
}
private updateAttributes() {
if (!this.currentTheme || !this.currentCompiledTheme) return;
const colorScheme = this.currentTheme.base === 'dark' ? 'dark' : 'light';
window.document.documentElement.dataset.colorScheme = colorScheme;
for (const tag of window.document.head.children) {
if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') {
tag.setAttribute('content', this.currentCompiledTheme['htmlThemeColor']);
break;
}
}
for (const key of themeProps) {
const value = this.currentCompiledTheme[key];
if (value) {
window.document.documentElement.style.setProperty(`--MI_THEME-${key}`, value.toString());
} else {
window.document.documentElement.style.removeProperty(`--MI_THEME-${key}`);
}
}
window.document.documentElement.style.setProperty('color-scheme', colorScheme);
this.emit('themeChanging');
}
}
export const themeManager = new ThemeManager();
export const isPreviewMode = ref(false);
themeManager.on('requestUpdateThemeCache', (theme, props) => {
miLocalStorage.setItem('theme', JSON.stringify(props));
miLocalStorage.setItem('themeId', theme.id);
miLocalStorage.setItem('themeCachedVersion', version);
});
themeManager.on('previewStateChanged', (preview) => {
isPreviewMode.value = preview;
});
export async function addTheme(theme: Theme): Promise<void> {
if ($i == null) return;
const builtinThemes = await getBuiltinThemes();
if (builtinThemes.some(t => t.id === theme.id)) {
throw new Error('builtin theme');
}
const themes = prefer.s.themes;
if (themes.some(t => t.id === theme.id)) {
throw new Error('already exists');
}
prefer.commit('themes', [...themes, theme]);
}
export async function removeTheme(theme: Theme): Promise<void> {
if ($i == null) return;
const themes = prefer.s.themes.filter(t => t.id !== theme.id);
prefer.commit('themes', themes);
}
export async function installTheme(code: string): Promise<void> {
const theme = parseThemeCode(code);
if (theme == null) return;
await addTheme(theme);
}
export function clearAppliedThemeCache() {
miLocalStorage.removeItem('theme');
miLocalStorage.removeItem('themeId');
miLocalStorage.removeItem('themeCachedVersion');
}
export function handleThemeInstallError(err: unknown) {
if (err instanceof Error) {
let message = '';
switch (err.message.toLowerCase()) {
case 'this theme is already installed':
case 'already exists':
case 'builtin theme':
message = i18n.ts._theme.alreadyInstalled;
break;
default:
message = i18n.ts._theme.invalid;
break;
}
os.alert({
type: 'error',
text: message,
});
}
console.error(err);
}