From 2918fb2609cf26401847116dcd57f88bf694643a Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Sun, 9 Mar 2025 14:32:29 +0900 Subject: refactor(frontend): relocate theme script --- packages/frontend/.storybook/preview.ts | 4 +- packages/frontend/@types/theme.d.ts | 2 +- packages/frontend/src/boot/common.ts | 2 +- packages/frontend/src/pages/install-extensions.vue | 2 +- .../frontend/src/pages/settings/theme.install.vue | 2 +- .../frontend/src/pages/settings/theme.manage.vue | 4 +- packages/frontend/src/pages/settings/theme.vue | 2 +- packages/frontend/src/pages/theme-editor.vue | 4 +- packages/frontend/src/preferences/def.ts | 2 +- packages/frontend/src/theme-store.ts | 4 +- packages/frontend/src/theme.ts | 189 +++++++++++++++++++++ packages/frontend/src/utility/theme-editor.ts | 4 +- packages/frontend/src/utility/theme.ts | 189 --------------------- 13 files changed, 205 insertions(+), 205 deletions(-) create mode 100644 packages/frontend/src/theme.ts delete mode 100644 packages/frontend/src/utility/theme.ts diff --git a/packages/frontend/.storybook/preview.ts b/packages/frontend/.storybook/preview.ts index 938e4a623f..c47b48bd7f 100644 --- a/packages/frontend/.storybook/preview.ts +++ b/packages/frontend/.storybook/preview.ts @@ -21,7 +21,7 @@ let moduleInitialized = false; let unobserve = () => {}; let misskeyOS = null; -function loadTheme(applyTheme: typeof import('../src/utility/theme')['applyTheme']) { +function loadTheme(applyTheme: typeof import('../src/theme')['applyTheme']) { unobserve(); const theme = themes[document.documentElement.dataset.misskeyTheme]; if (theme) { @@ -67,7 +67,7 @@ queueMicrotask(() => { import('../src/components'), import('../src/directives'), import('../src/widgets'), - import('../src/utility/theme'), + import('../src/theme'), import('../src/preferences'), import('../src/os'), ]).then(([{ default: components }, { default: directives }, { default: widgets }, { applyTheme }, { prefer }, os]) => { diff --git a/packages/frontend/@types/theme.d.ts b/packages/frontend/@types/theme.d.ts index 473e386be9..6ac1037493 100644 --- a/packages/frontend/@types/theme.d.ts +++ b/packages/frontend/@types/theme.d.ts @@ -4,7 +4,7 @@ */ declare module '@@/themes/*.json5' { - import { Theme } from '@/utility/theme.js'; + import { Theme } from '@/theme.js'; const theme: Theme; diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index d66ff21519..90d80f02c6 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -12,7 +12,7 @@ import type { App } from 'vue'; import widgets from '@/widgets/index.js'; import directives from '@/directives/index.js'; import components from '@/components/index.js'; -import { applyTheme } from '@/utility/theme.js'; +import { applyTheme } from '@/theme.js'; import { isDeviceDarkmode } from '@/utility/is-device-darkmode.js'; import { updateI18n, i18n } from '@/i18n.js'; import { $i, refreshAccount, login } from '@/account.js'; diff --git a/packages/frontend/src/pages/install-extensions.vue b/packages/frontend/src/pages/install-extensions.vue index 055ee8a609..0d59c90527 100644 --- a/packages/frontend/src/pages/install-extensions.vue +++ b/packages/frontend/src/pages/install-extensions.vue @@ -56,7 +56,7 @@ import FormSection from '@/components/form/section.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { parsePluginMeta, installPlugin } from '@/plugin.js'; -import { parseThemeCode, installTheme } from '@/utility/theme.js'; +import { parseThemeCode, installTheme } from '@/theme.js'; import { unisonReload } from '@/utility/unison-reload.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/utility/page-metadata.js'; diff --git a/packages/frontend/src/pages/settings/theme.install.vue b/packages/frontend/src/pages/settings/theme.install.vue index 536e624a2d..c731f343e2 100644 --- a/packages/frontend/src/pages/settings/theme.install.vue +++ b/packages/frontend/src/pages/settings/theme.install.vue @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, computed } from 'vue'; import MkCodeEditor from '@/components/MkCodeEditor.vue'; import MkButton from '@/components/MkButton.vue'; -import { parseThemeCode, previewTheme, installTheme } from '@/utility/theme.js'; +import { parseThemeCode, previewTheme, installTheme } from '@/theme.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/utility/page-metadata.js'; diff --git a/packages/frontend/src/pages/settings/theme.manage.vue b/packages/frontend/src/pages/settings/theme.manage.vue index 2b952cb4e3..cc730cf4f0 100644 --- a/packages/frontend/src/pages/settings/theme.manage.vue +++ b/packages/frontend/src/pages/settings/theme.manage.vue @@ -37,8 +37,8 @@ import MkTextarea from '@/components/MkTextarea.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkInput from '@/components/MkInput.vue'; import MkButton from '@/components/MkButton.vue'; -import { getBuiltinThemesRef } from '@/utility/theme.js'; -import type { Theme } from '@/utility/theme.js'; +import { getBuiltinThemesRef } from '@/theme.js'; +import type { Theme } from '@/theme.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import * as os from '@/os.js'; import { getThemes, removeTheme } from '@/theme-store.js'; diff --git a/packages/frontend/src/pages/settings/theme.vue b/packages/frontend/src/pages/settings/theme.vue index 0254dbda5b..397c387af6 100644 --- a/packages/frontend/src/pages/settings/theme.vue +++ b/packages/frontend/src/pages/settings/theme.vue @@ -83,7 +83,7 @@ import MkSelect from '@/components/MkSelect.vue'; import FormSection from '@/components/form/section.vue'; import FormLink from '@/components/form/link.vue'; import MkButton from '@/components/MkButton.vue'; -import { getBuiltinThemesRef } from '@/utility/theme.js'; +import { getBuiltinThemesRef } from '@/theme.js'; import { selectFile } from '@/utility/select-file.js'; import { isDeviceDarkmode } from '@/utility/is-device-darkmode.js'; import { store } from '@/store.js'; diff --git a/packages/frontend/src/pages/theme-editor.vue b/packages/frontend/src/pages/theme-editor.vue index 50445d8a34..391ec01a1d 100644 --- a/packages/frontend/src/pages/theme-editor.vue +++ b/packages/frontend/src/pages/theme-editor.vue @@ -81,13 +81,13 @@ import JSON5 from 'json5'; import lightTheme from '@@/themes/_light.json5'; import darkTheme from '@@/themes/_dark.json5'; import { host } from '@@/js/config.js'; -import type { Theme } from '@/utility/theme.js'; +import type { Theme } from '@/theme.js'; import MkButton from '@/components/MkButton.vue'; import MkCodeEditor from '@/components/MkCodeEditor.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkFolder from '@/components/MkFolder.vue'; import { $i } from '@/account.js'; -import { applyTheme } from '@/utility/theme.js'; +import { applyTheme } from '@/theme.js'; import * as os from '@/os.js'; import { store } from '@/store.js'; import { addTheme } from '@/theme-store.js'; diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts index d17cff0f5d..e122a25d4e 100644 --- a/packages/frontend/src/preferences/def.ts +++ b/packages/frontend/src/preferences/def.ts @@ -5,7 +5,7 @@ import * as Misskey from 'misskey-js'; import { hemisphere } from '@@/js/intl-const.js'; -import type { Theme } from '@/utility/theme.js'; +import type { Theme } from '@/theme.js'; import type { SoundType } from '@/utility/sound.js'; import type { Plugin } from '@/plugin.js'; import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js'; diff --git a/packages/frontend/src/theme-store.ts b/packages/frontend/src/theme-store.ts index 93fbe395f9..c02005a15c 100644 --- a/packages/frontend/src/theme-store.ts +++ b/packages/frontend/src/theme-store.ts @@ -3,8 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import type { Theme } from '@/utility/theme.js'; -import { getBuiltinThemes } from '@/utility/theme.js'; +import type { Theme } from '@/theme.js'; +import { getBuiltinThemes } from '@/theme.js'; import { $i } from '@/account.js'; import { prefer } from '@/preferences.js'; diff --git a/packages/frontend/src/theme.ts b/packages/frontend/src/theme.ts new file mode 100644 index 0000000000..0f44d777f9 --- /dev/null +++ b/packages/frontend/src/theme.ts @@ -0,0 +1,189 @@ +/* + * 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; + codeHighlighter?: { + base: BundledTheme; + overrides?: Record; + } | { + base: '_none_'; + overrides: Record; + }; +}; + +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([]); + 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); + + document.documentElement.classList.add('_themeChanging_'); + + timeout = window.setTimeout(() => { + document.documentElement.classList.remove('_themeChanging_'); + }, 1000); + + const colorScheme = theme.base === 'dark' ? 'dark' : 'light'; + + 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 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)) { + document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString()); + } + + 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('themeChanged'); +} + +function compile(theme: Theme): Record { + 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): 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 { + const theme = parseThemeCode(code); + if (!theme) return; + await addTheme(theme); +} diff --git a/packages/frontend/src/utility/theme-editor.ts b/packages/frontend/src/utility/theme-editor.ts index 0206e378bf..ea07e5f2ff 100644 --- a/packages/frontend/src/utility/theme-editor.ts +++ b/packages/frontend/src/utility/theme-editor.ts @@ -5,8 +5,8 @@ import { v4 as uuid } from 'uuid'; -import { themeProps } from './theme.js'; -import type { Theme } from './theme.js'; +import type { Theme } from '@/theme.js'; +import { themeProps } from '@/theme.js'; export type Default = null; export type Color = string; diff --git a/packages/frontend/src/utility/theme.ts b/packages/frontend/src/utility/theme.ts deleted file mode 100644 index 851ba41e61..0000000000 --- a/packages/frontend/src/utility/theme.ts +++ /dev/null @@ -1,189 +0,0 @@ -/* - * 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 { deepClone } from './clone.js'; -import type { BundledTheme } from 'shiki/themes'; -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; - codeHighlighter?: { - base: BundledTheme; - overrides?: Record; - } | { - base: '_none_'; - overrides: Record; - }; -}; - -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([]); - 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); - - document.documentElement.classList.add('_themeChanging_'); - - timeout = window.setTimeout(() => { - document.documentElement.classList.remove('_themeChanging_'); - }, 1000); - - const colorScheme = theme.base === 'dark' ? 'dark' : 'light'; - - 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 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)) { - document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString()); - } - - 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('themeChanged'); -} - -function compile(theme: Theme): Record { - 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): 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 { - const theme = parseThemeCode(code); - if (!theme) return; - await addTheme(theme); -} -- cgit v1.2.3-freya