1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-05-07 08:45:33 +02:00

Compare commits

..

2 Commits

Author SHA1 Message Date
かっこかり
b73ac26612 Update CHANGELOG.md 2026-05-07 13:37:36 +09:00
かっこかり
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
50 changed files with 1243 additions and 506 deletions

View File

@@ -4,8 +4,10 @@
-
### Client
- 2025.4.0 以前の設定情報の移行処理が削除されました
- 2025.4.0 から直接 2026.6.0 以上にアップデートする場合は設定が移行されませんので注意してください。移行したい場合は一度 2026.5.1 を経由してください。
- Enhance: テーマのプレビュー時、リロードせずにもとのテーマに戻せるように
- Fix: テーマエディター使用時に、最初の変更のみ適用される問題を修正
- Fix: テーマのプレビュー時、既存のテーマとIDが被っている場合にプレビューできない問題を修正
- Fix: テーマのインストールエラーの表示を改善
### Server
-

View File

@@ -1358,11 +1358,14 @@ information: "情報"
chat: "チャット"
directMessage: "ダイレクトメッセージ"
directMessage_short: "メッセージ"
migrateOldSettings: "旧設定情報を移行"
migrateOldSettings_description: "通常これは自動で行われていますが、何らかの理由により上手く移行されなかった場合は手動で移行処理をトリガーできます。現在の設定情報は上書きされます。"
compress: "圧縮"
right: "右"
bottom: "下"
top: "上"
embed: "埋め込み"
settingsMigrating: "設定を移行しています。しばらくお待ちください... (後ほど、設定→その他→旧設定情報を移行 で手動で移行することもできます)"
readonly: "読み取り専用"
goToDeck: "デッキへ戻る"
federationJobs: "連合ジョブ"
@@ -1406,6 +1409,8 @@ presets: "プリセット"
zeroPadding: "ゼロ埋め"
nothingToConfigure: "設定項目はありません"
viewRenotedChannel: "リノート先のチャンネルを見る"
previewingTheme: "テーマのプレビュー中"
previewingThemeRestore: "元に戻す"
_imageEditing:
_vars:

View File

@@ -28,7 +28,7 @@ import { postMessageToParentWindow, setIframeId } from '@/post-message.js';
import { serverContext } from '@/server-context.js';
import { i18n } from '@/i18n.js';
import type { Theme } from '@/theme.js';
import type { Theme } from '@@/js/theme.js';
console.log('Misskey Embed');

View File

@@ -5,26 +5,10 @@
// TODO: (可能な部分を)sharedに抽出して frontend と共通化
import tinycolor from 'tinycolor2';
import lightTheme from '@@/themes/_light.json5';
import darkTheme from '@@/themes/_dark.json5';
import type { BundledTheme } from 'shiki/themes';
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>;
};
};
import { compile } from '@@/js/theme.js';
import type { Theme } from '@@/js/theme.js';
let timeout: number | null = null;
@@ -32,7 +16,7 @@ export function assertIsTheme(theme: Record<string, unknown>): theme is Theme {
return typeof theme === 'object' && theme !== null && 'id' in theme && 'name' in theme && 'author' in theme && 'props' in theme;
}
export function applyTheme(theme: Theme, persist = true) {
export function applyTheme(theme: Theme) {
if (timeout) window.clearTimeout(timeout);
window.document.documentElement.classList.add('_themeChanging_');
@@ -68,48 +52,3 @@ export function applyTheme(theme: Theme, persist = true) {
// iframeを正常に透過させるために、cssのcolor-schemeは `light dark;` 固定にしてある。style.scss参照
}
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 funcTxt = parts.shift();
const argTxt = parts.shift();
if (funcTxt && argTxt) {
const func = funcTxt.substring(1);
const arg = parseFloat(argTxt);
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();
}

View File

@@ -0,0 +1,13 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
declare module '@@/themes/*.json5' {
import { Theme } from '@@/js/theme.js';
const theme: Theme;
// eslint-disable-next-line import/no-default-export
export default theme;
}

View File

@@ -1,109 +0,0 @@
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import * as esbuild from 'esbuild';
import { build } from 'esbuild';
import { execa } from 'execa';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const _package = JSON.parse(fs.readFileSync(_dirname + '/package.json', 'utf-8'));
const entryPoints = fs.globSync('./js/**/**.{ts,tsx}');
/** @type {import('esbuild').BuildOptions} */
const options = {
entryPoints,
minify: process.env.NODE_ENV === 'production',
outdir: './js-built',
target: 'es2022',
platform: 'browser',
format: 'esm',
sourcemap: 'linked',
};
const args = process.argv.slice(2).map(arg => arg.toLowerCase());
// js-built配下をすべて削除する
if (!args.includes('--no-clean')) {
fs.rmSync('./js-built', { recursive: true, force: true });
}
if (args.includes('--watch')) {
await watchSrc();
} else {
await buildSrc();
}
async function buildSrc() {
console.log(`[${_package.name}] start building...`);
await build(options)
.then(() => {
console.log(`[${_package.name}] build succeeded.`);
})
.catch((err) => {
process.stderr.write(err.stderr);
process.exit(1);
});
if (process.env.NODE_ENV === 'production') {
console.log(`[${_package.name}] skip building d.ts because NODE_ENV is production.`);
} else {
await buildDts();
}
fs.copyFileSync('./js/emojilist.json', './js-built/emojilist.json');
console.log(`[${_package.name}] finish building.`);
}
function buildDts() {
return execa(
'tsgo',
[
'--project', 'tsconfig.json',
'--outDir', 'js-built',
'--declaration', 'true',
'--emitDeclarationOnly', 'true',
],
{
stdout: process.stdout,
stderr: process.stderr,
},
);
}
async function watchSrc() {
const plugins = [{
name: 'gen-dts',
setup(build) {
build.onStart(() => {
console.log(`[${_package.name}] detect changed...`);
});
build.onEnd(async result => {
if (result.errors.length > 0) {
console.error(`[${_package.name}] watch build failed:`, result);
return;
}
await buildDts();
});
},
}];
console.log(`[${_package.name}] start watching...`);
const context = await esbuild.context({ ...options, plugins });
await context.watch();
await new Promise((resolve, reject) => {
process.on('SIGHUP', resolve);
process.on('SIGINT', resolve);
process.on('SIGTERM', resolve);
process.on('uncaughtException', reject);
process.on('exit', resolve);
}).finally(async () => {
await context.dispose();
console.log(`[${_package.name}] finish watching.`);
});
}

View File

@@ -0,0 +1,126 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import tinycolor from 'tinycolor2';
import JSON5 from 'json5';
import lightTheme from '@@/themes/_light.json5';
import type { BundledTheme } from 'shiki/themes';
export type Theme = {
id: string;
name: string;
author: string;
desc?: string;
base?: 'dark' | 'light';
kind?: 'dark' | 'light'; // legacy
props: Record<string, string>;
codeHighlighter?: {
base: BundledTheme;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
overrides?: Record<string, any>;
} | {
base: '_none_';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
overrides: Record<string, any>;
};
};
export type CompiledTheme = Record<string, string>;
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 function compile(theme: Theme): CompiledTheme {
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 funcTxt = parts.shift();
const argTxt = parts.shift();
if (funcTxt && argTxt) {
const func = funcTxt.substring(1);
const arg = parseFloat(argTxt);
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 = {} as CompiledTheme;
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();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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 (_) {
throw new Error('Failed to parse theme json');
}
if (!validateTheme(theme)) {
throw new Error('This theme is invaild');
}
return theme;
}

View File

@@ -1,32 +1,18 @@
{
"name": "frontend-shared",
"type": "module",
"main": "./js-built/index.js",
"types": "./js-built/index.d.ts",
"exports": {
".": {
"import": "./js-built/index.js",
"types": "./js-built/index.d.ts"
},
"./*": {
"import": "./js-built/*",
"types": "./js-built/*"
}
},
"private": true,
"scripts": {
"build": "node ./build.js",
"watch": "nodemon -w package.json -e json --exec \"node ./build.js --watch\"",
"eslint": "eslint './**/*.{js,jsx,ts,tsx}'",
"typecheck": "tsgo --noEmit",
"lint": "pnpm typecheck && pnpm eslint"
},
"devDependencies": {
"@types/node": "24.12.2",
"@types/tinycolor2": "1.4.6",
"@typescript-eslint/eslint-plugin": "8.59.0",
"@typescript-eslint/parser": "8.59.0",
"esbuild": "0.28.0",
"eslint-plugin-vue": "10.9.0",
"nodemon": "3.1.14",
"vue-eslint-parser": "10.4.0"
},
"files": [
@@ -34,7 +20,10 @@
],
"dependencies": {
"i18n": "workspace:*",
"json5": "2.2.3",
"misskey-js": "workspace:*",
"shiki": "4.0.2",
"tinycolor2": "1.6.0",
"vue": "3.5.33"
}
}

View File

@@ -20,13 +20,13 @@ let moduleInitialized = false;
let unobserve = () => {};
let misskeyOS = null;
function loadTheme(applyTheme: typeof import('../src/theme')['applyTheme']) {
function loadTheme(themeMaganer: typeof import('../src/theme')['themeManager']) {
unobserve();
const theme = themes[window.document.documentElement.dataset.misskeyTheme];
if (theme) {
applyTheme(themes[window.document.documentElement.dataset.misskeyTheme]);
themeMaganer.updateTheme(themes[window.document.documentElement.dataset.misskeyTheme]);
} else {
applyTheme(themes['l-light']);
themeMaganer.updateTheme(themes['l-light']);
}
const observer = new MutationObserver((entries) => {
for (const entry of entries) {
@@ -34,7 +34,7 @@ function loadTheme(applyTheme: typeof import('../src/theme')['applyTheme']) {
const target = entry.target as HTMLElement;
const theme = themes[target.dataset.misskeyTheme];
if (theme) {
applyTheme(themes[target.dataset.misskeyTheme]);
themeMaganer.updateTheme(themes[target.dataset.misskeyTheme]);
} else {
target.removeAttribute('style');
}

View File

@@ -13,7 +13,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 '@/theme.js';
import { themeManager } from '@/theme.js';
import { isDeviceDarkmode } from '@/utility/is-device-darkmode.js';
import { i18n } from '@/i18n.js';
import { refreshCurrentAccount, login } from '@/accounts.js';
@@ -161,7 +161,7 @@ export async function common(createVue: () => Promise<App<Element>>) {
// NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため)
// NOTE: この処理は必ずダークモード判定処理より後に来ること(初回のテーマ適用のため)
// NOTE: この処理は必ずサーバーテーマ適用処理より後に来ること(二重applyTheme発火を防ぐため)
// NOTE: この処理は必ずサーバーテーマ適用処理より後に来ること(二重発火を防ぐため)
// see: https://github.com/misskey-dev/misskey/issues/16562
watch(store.r.darkMode, (darkMode) => {
const theme = (() => {
@@ -172,7 +172,7 @@ export async function common(createVue: () => Promise<App<Element>>) {
}
})();
applyTheme(theme);
themeManager.updateTheme(theme);
}, { immediate: true });
window.document.documentElement.dataset.colorScheme = store.s.darkMode ? 'dark' : 'light';
@@ -180,13 +180,13 @@ export async function common(createVue: () => Promise<App<Element>>) {
if (!isSafeMode) {
watch(prefer.r.darkTheme, (theme) => {
if (store.s.darkMode) {
applyTheme(theme ?? defaultDarkTheme);
themeManager.updateTheme(theme ?? defaultDarkTheme);
}
});
watch(prefer.r.lightTheme, (theme) => {
if (!store.s.darkMode) {
applyTheme(theme ?? defaultLightTheme);
themeManager.updateTheme(theme ?? defaultLightTheme);
}
});
}

View File

@@ -27,6 +27,7 @@ import { makeHotkey } from '@/utility/hotkey.js';
import { addCustomEmoji, removeCustomEmojis, updateCustomEmojis } from '@/custom-emojis.js';
import { prefer } from '@/preferences.js';
import { updateCurrentAccountPartial } from '@/accounts.js';
import { migrateOldSettings } from '@/pref-migrate.js';
import { unisonReload } from '@/utility/unison-reload.js';
import { isBirthday } from '@/utility/is-birthday.js';
@@ -68,6 +69,14 @@ export async function mainBoot() {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, {
closed: () => dispose(),
});
// prefereces migration
// TODO: そのうち消す
if (lastVersion && (compareVersions('2025.3.2-alpha.0', lastVersion) === 1)) {
console.log('Preferences migration');
migrateOldSettings();
}
}
try {

View File

@@ -81,7 +81,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, onMounted, onBeforeUnmount, ref } from 'vue';
import tinycolor from 'tinycolor2';
import { globalEvents } from '@/events.js';
import { themeManager } from '@/theme.js';
import { defaultIdlingRenderScheduler } from '@/utility/idle-render.js';
// https://stackoverflow.com/questions/1878907/how-can-i-find-the-difference-between-two-angles
@@ -192,13 +192,13 @@ function tick() {
tick();
function calcColors() {
const computedStyle = getComputedStyle(window.document.documentElement);
const dark = tinycolor(computedStyle.getPropertyValue('--MI_THEME-bg')).isDark();
const accent = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString();
const themeValue = themeManager.currentCompiledTheme!;
const dark = tinycolor(themeValue.bg).isDark();
const accent = tinycolor(themeValue.accent).toHexString();
majorGraduationColor.value = dark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)';
//minorGraduationColor = dark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
sHandColor.value = dark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)';
mHandColor.value = tinycolor(computedStyle.getPropertyValue('--MI_THEME-fg')).toHexString();
mHandColor.value = tinycolor(themeValue.fg).toHexString();
hHandColor.value = accent;
nowColor.value = accent;
}
@@ -207,13 +207,13 @@ calcColors();
onMounted(() => {
defaultIdlingRenderScheduler.add(tick);
globalEvents.on('themeChanged', calcColors);
themeManager.on('themeChanged', calcColors);
});
onBeforeUnmount(() => {
enabled = false;
defaultIdlingRenderScheduler.delete(tick);
globalEvents.off('themeChanged', calcColors);
themeManager.off('themeChanged', calcColors);
});
</script>

View File

@@ -34,7 +34,7 @@ import * as Misskey from 'misskey-js';
import Cropper from 'cropperjs';
import tinycolor from 'tinycolor2';
import MkModalWindow from '@/components/MkModalWindow.vue';
import * as os from '@/os.js';
import { themeManager } from '@/theme.js';
import { i18n } from '@/i18n.js';
const props = defineProps<{
@@ -105,10 +105,10 @@ onMounted(() => {
cropper = new Cropper(imgEl.value, {
});
const computedStyle = getComputedStyle(window.document.documentElement);
const themeValue = themeManager.currentCompiledTheme!;
const selection = cropper.getCropperSelection()!;
selection.themeColor = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString();
selection.themeColor = tinycolor(themeValue.accent).toHexString();
if (props.aspectRatio != null) selection.aspectRatio = props.aspectRatio;
selection.initialAspectRatio = props.aspectRatio ?? 1;
selection.outlined = true;

View File

@@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
import { miLocalStorage } from '@/local-storage.js';
import { prefer } from '@/preferences.js';
import { globalEvents } from '@/events.js';
import { themeManager } from '@/theme.js';
import { getBgColor } from '@/utility/get-bg-color.js';
const miLocalStoragePrefix = 'ui:folder:' as const;
@@ -92,11 +92,11 @@ function updateBgColor() {
onMounted(() => {
updateBgColor();
globalEvents.on('themeChanging', updateBgColor);
themeManager.on('themeChanging', updateBgColor);
});
onBeforeUnmount(() => {
globalEvents.off('themeChanging', updateBgColor);
themeManager.off('themeChanging', updateBgColor);
});
</script>

View File

@@ -100,6 +100,7 @@ import { nextTick, onMounted, ref, useTemplateRef, watch } from 'vue';
import { prefer } from '@/preferences.js';
import { getBgColor } from '@/utility/get-bg-color.js';
import { pageFolderTeleportCount, popup } from '@/os.js';
import { themeManager } from '@/theme.js';
import MkFolderPage from '@/components/MkFolderPage.vue';
import { deviceKind } from '@/utility/device-kind.js';
@@ -192,9 +193,9 @@ async function toggle(ev: PointerEvent) {
}
onMounted(() => {
const computedStyle = getComputedStyle(window.document.documentElement);
const themeValue = themeManager.currentCompiledTheme!;
const parentBg = getBgColor(rootEl.value?.parentElement) ?? 'transparent';
const myBg = computedStyle.getPropertyValue('--MI_THEME-panel');
const myBg = themeValue.panel;
bgSame.value = parentBg === myBg;
});

View File

@@ -73,6 +73,7 @@ import MkRetentionHeatmap from '@/components/MkRetentionHeatmap.vue';
import MkRetentionLineChart from '@/components/MkRetentionLineChart.vue';
import { initChart } from '@/utility/init-chart.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
import { themeManager } from '@/theme.js';
initChart();
@@ -186,7 +187,7 @@ function createDoughnut(chartEl: HTMLCanvasElement, tooltip: ReturnType<typeof u
labels: data.map(x => x.name),
datasets: [{
backgroundColor: data.map(x => x.color),
borderColor: getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-panel'),
borderColor: themeManager.currentCompiledTheme!.panel,
borderWidth: 2,
hoverOffset: 0,
data: data.map(x => x.value),

View File

@@ -33,6 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { watch, ref } from 'vue';
import { genId } from '@/utility/id.js';
import { themeManager } from '@/theme.js';
import tinycolor from 'tinycolor2';
import { useInterval } from '@@/js/use-interval.js';
@@ -47,8 +48,7 @@ const polylinePoints = ref('');
const polygonPoints = ref('');
const headX = ref<number | null>(null);
const headY = ref<number | null>(null);
const clock = ref<number | null>(null);
const accent = tinycolor(getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-accent'));
const accent = tinycolor(themeManager.currentCompiledTheme!.accent);
const color = accent.toRgbString();
function draw(): void {

View File

@@ -13,6 +13,7 @@ import { Chart } from 'chart.js';
import type { ScatterDataPoint } from 'chart.js';
import tinycolor from 'tinycolor2';
import { store } from '@/store.js';
import { themeManager } from '@/theme.js';
import { useChartTooltip } from '@/composables/use-chart-tooltip.js';
import { chartVLine } from '@/utility/chart-vline.js';
import { alpha } from '@/utility/color.js';
@@ -51,7 +52,7 @@ onMounted(async () => {
const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
const accent = tinycolor(getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-accent'));
const accent = tinycolor(themeManager.currentCompiledTheme!.accent);
const color = accent.toHex();
if (chartEl.value == null) return;

View File

@@ -16,11 +16,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { onMounted, watch, onBeforeUnmount, ref, useTemplateRef } from 'vue';
import { themeManager } from '@/theme.js';
import tinycolor from 'tinycolor2';
const loaded = !!window.TagCanvas;
const SAFE_FOR_HTML_ID = 'abcdefghijklmnopqrstuvwxyz';
const computedStyle = getComputedStyle(window.document.documentElement);
const idForCanvas = Array.from({ length: 16 }, () => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join('');
const idForTags = Array.from({ length: 16 }, () => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join('');
const available = ref(false);
@@ -33,7 +33,7 @@ watch(available, () => {
try {
window.TagCanvas.Start(idForCanvas, idForTags, {
textColour: '#ffffff',
outlineColour: tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString(),
outlineColour: tinycolor(themeManager.currentCompiledTheme!.accent).toHexString(),
outlineRadius: 10,
initial: [-0.030, -0.010],
frontSelect: true,

View File

@@ -43,8 +43,8 @@ SPDX-License-Identifier: AGPL-3.0-only
import { ref, watch } from 'vue';
import lightTheme from '@@/themes/_light.json5';
import darkTheme from '@@/themes/_dark.json5';
import type { Theme } from '@/theme.js';
import { compile } from '@/theme.js';
import type { Theme } from '@@/js/theme.js';
import { compile } from '@@/js/theme.js';
import { deepClone } from '@/utility/clone.js';
const props = defineProps<{

View File

@@ -15,9 +15,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { onMounted, useTemplateRef, ref, nextTick } from 'vue';
import { Chart } from 'chart.js';
import gradient from 'chartjs-plugin-gradient';
import tinycolor from 'tinycolor2';
import { misskeyApi } from '@/utility/misskey-api.js';
import { themeManager } from '@/theme.js';
import { store } from '@/store.js';
import { useChartTooltip } from '@/composables/use-chart-tooltip.js';
import { chartVLine } from '@/utility/chart-vline.js';
@@ -61,8 +61,7 @@ async function renderChart() {
const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
const computedStyle = getComputedStyle(window.document.documentElement);
const accent = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString();
const accent = tinycolor(themeManager.currentCompiledTheme!.accent).toHexString();
const colorRead = accent;
const colorWrite = '#2ecc71';

View File

@@ -324,8 +324,9 @@ function onTopHandlePointerdown(evt: PointerEvent) {
if (main == null) return;
const base = getPositionY(evt);
const height = parseInt(getComputedStyle(main, '').height, 10);
const top = parseInt(getComputedStyle(main, '').top, 10);
const computedStyle = getComputedStyle(main, '');
const height = parseInt(computedStyle.height, 10);
const top = parseInt(computedStyle.top, 10);
// 動かした時
dragListen(me => {
@@ -353,8 +354,9 @@ function onRightHandlePointerdown(evt: PointerEvent) {
if (main == null) return;
const base = getPositionX(evt);
const width = parseInt(getComputedStyle(main, '').width, 10);
const left = parseInt(getComputedStyle(main, '').left, 10);
const computedStyle = getComputedStyle(main, '');
const width = parseInt(computedStyle.width, 10);
const left = parseInt(computedStyle.left, 10);
const browserWidth = window.innerWidth;
// 動かした時
@@ -380,8 +382,9 @@ function onBottomHandlePointerdown(evt: PointerEvent) {
if (main == null) return;
const base = getPositionY(evt);
const height = parseInt(getComputedStyle(main, '').height, 10);
const top = parseInt(getComputedStyle(main, '').top, 10);
const computedStyle = getComputedStyle(main, '');
const height = parseInt(computedStyle.height, 10);
const top = parseInt(computedStyle.top, 10);
const browserHeight = window.innerHeight;
// 動かした時
@@ -407,8 +410,9 @@ function onLeftHandlePointerdown(evt: PointerEvent) {
if (main == null) return;
const base = getPositionX(evt);
const width = parseInt(getComputedStyle(main, '').width, 10);
const left = parseInt(getComputedStyle(main, '').left, 10);
const computedStyle = getComputedStyle(main, '');
const width = parseInt(computedStyle.width, 10);
const left = parseInt(computedStyle.left, 10);
// 動かした時
dragListen(me => {

View File

@@ -5,7 +5,7 @@
import type { Directive } from 'vue';
import { getBgColor } from '@/utility/get-bg-color.js';
import { globalEvents } from '@/events.js';
import { themeManager } from '@/theme.js';
const handlerMap = new WeakMap<HTMLElement, () => void>();
@@ -27,10 +27,10 @@ export const adaptiveBorderDirective = {
calc();
globalEvents.on('themeChanged', calc);
themeManager.on('themeChanged', calc);
},
unmounted(src) {
globalEvents.off('themeChanged', handlerMap.get(src));
themeManager.off('themeChanged', handlerMap.get(src));
},
} as Directive<HTMLElement>;

View File

@@ -4,13 +4,14 @@
*/
import type { Directive } from 'vue';
import { themeManager } from '@/theme.js';
import { getBgColor } from '@/utility/get-bg-color.js';
export const panelDirective = {
mounted(src) {
const parentBg = getBgColor(src.parentElement) ?? 'transparent';
const myBg = getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-panel');
const myBg = themeManager.currentCompiledTheme!.panel;
if (parentBg === myBg) {
src.style.backgroundColor = 'var(--MI_THEME-bg)';

View File

@@ -8,8 +8,6 @@ import * as Misskey from 'misskey-js';
import { onBeforeUnmount } from 'vue';
type Events = {
themeChanging: () => void;
themeChanged: () => void;
clientNotification: (notification: Misskey.entities.Notification) => void;
notePosted: (note: Misskey.entities.Note) => void;
noteDeleted: (noteId: Misskey.entities.Note['id']) => void;

View File

@@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { onMounted, useTemplateRef } from 'vue';
import { Chart } from 'chart.js';
import { themeManager } from '@/theme.js';
import { useChartTooltip } from '@/composables/use-chart-tooltip.js';
import { initChart } from '@/utility/init-chart.js';
@@ -43,7 +44,7 @@ onMounted(() => {
labels: props.data.map(x => x.name),
datasets: [{
backgroundColor: props.data.map(x => x.color ?? '#000'),
borderColor: getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-panel'),
borderColor: themeManager.currentCompiledTheme!.panel,
borderWidth: 2,
hoverOffset: 0,
data: props.data.map(x => x.value),

View File

@@ -53,7 +53,8 @@ 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 '@/theme.js';
import { installTheme } from '@/theme.js';
import { parseThemeCode } from '@@/js/theme.js';
import { unisonReload } from '@/utility/unison-reload.js';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';

View File

@@ -145,6 +145,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton v-if="storagePersistenceSupported && !storagePersisted" @click="enableStoragePersistence">{{ i18n.ts._settings.settingsPersistence_title }}</MkButton>
<MkButton @click="forceCloudBackup">{{ i18n.ts._preferencesBackup.forceBackup }}</MkButton>
<FormSlot>
<MkButton danger @click="migrate"><i class="ti ti-refresh"></i> {{ i18n.ts.migrateOldSettings }}</MkButton>
<template #caption>{{ i18n.ts.migrateOldSettings_description }}</template>
</FormSlot>
</div>
</SearchMarker>
</template>
@@ -168,6 +173,7 @@ import FormSection from '@/components/form/section.vue';
import { prefer } from '@/preferences.js';
import MkRolePreview from '@/components/MkRolePreview.vue';
import { signout } from '@/signout.js';
import { migrateOldSettings } from '@/pref-migrate.js';
import { hideAllTips as _hideAllTips, resetAllTips as _resetAllTips } from '@/tips.js';
import { suggestReload } from '@/utility/reload-suggest.js';
import { cloudBackup } from '@/preferences/utility.js';
@@ -213,6 +219,10 @@ async function deleteAccount() {
await signout();
}
function migrate() {
migrateOldSettings();
}
function resetAllTips() {
_resetAllTips();
os.success();

View File

@@ -20,7 +20,8 @@ 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 '@/theme.js';
import { themeManager, installTheme, handleThemeInstallError } from '@/theme.js';
import { parseThemeCode } from '@@/js/theme.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
@@ -29,6 +30,19 @@ import { useRouter } from '@/router.js';
const router = useRouter();
const installThemeCode = ref<string | null>(null);
function previewTheme(code: string): void {
try {
const theme = parseThemeCode(code);
themeManager.previewTheme(theme);
} catch (err) {
os.alert({
type: 'error',
text: i18n.ts._theme.invalid,
});
console.error(err);
}
}
async function install(code: string): Promise<void> {
try {
const theme = parseThemeCode(code);
@@ -40,22 +54,7 @@ async function install(code: string): Promise<void> {
installThemeCode.value = null;
router.push('/settings/theme');
} catch (err: any) {
switch (err.message.toLowerCase()) {
case 'this theme is already installed':
os.alert({
type: 'info',
text: i18n.ts._theme.alreadyInstalled,
});
break;
default:
os.alert({
type: 'error',
text: i18n.ts._theme.invalid,
});
break;
}
console.error(err);
handleThemeInstallError(err);
}
}

View File

@@ -27,21 +27,27 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, ref } from 'vue';
import JSON5 from 'json5';
import type { Theme } from '@/theme.js';
import type { Theme } from '@@/js/theme.js';
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, getThemesRef, removeTheme } from '@/theme.js';
import { removeTheme } from '@/theme.js';
import { getBuiltinThemes } from '@@/js/theme.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
import type { MkSelectItem } from '@/components/MkSelect.vue';
import { prefer } from '@/preferences';
const installedThemes = prefer.r.themes;
const builtinThemes = ref<Theme[]>([]);
getBuiltinThemes().then(themes => {
builtinThemes.value = themes;
});
const installedThemes = getThemesRef();
const builtinThemes = getBuiltinThemesRef();
const {
model: selectedThemeId,
def: selectedThemeIdDef,

View File

@@ -210,7 +210,7 @@ import JSON5 from 'json5';
import defaultLightTheme from '@@/themes/l-light.json5';
import defaultDarkTheme from '@@/themes/d-green-lime.json5';
import { isSafeMode } from '@@/js/config.js';
import type { Theme } from '@/theme.js';
import type { Theme } from '@@/js/theme.js';
import * as os from '@/os.js';
import MkSwitch from '@/components/MkSwitch.vue';
import FormSection from '@/components/form/section.vue';
@@ -218,7 +218,8 @@ import FormLink from '@/components/form/link.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkThemePreview from '@/components/MkThemePreview.vue';
import MkInfo from '@/components/MkInfo.vue';
import { getBuiltinThemesRef, getThemesRef, installTheme, parseThemeCode, removeTheme } from '@/theme.js';
import { handleThemeInstallError, installTheme, removeTheme } from '@/theme.js';
import { getBuiltinThemes } from '@@/js/theme.js';
import { isDeviceDarkmode } from '@/utility/is-device-darkmode.js';
import { store } from '@/store.js';
import { i18n } from '@/i18n.js';
@@ -229,8 +230,11 @@ import { prefer } from '@/preferences.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { checkDragDataType, getDragData, getPlainDragData, setDragData, setPlainDragData } from '@/drag-and-drop.js';
const installedThemes = getThemesRef();
const builtinThemes = getBuiltinThemesRef();
const installedThemes = prefer.r.themes;
const builtinThemes = ref<Theme[]>([]);
getBuiltinThemes().then(themes => {
builtinThemes.value = themes;
});
const instanceDarkTheme = computed<Theme | null>(() => instance.defaultDarkTheme ? JSON5.parse(instance.defaultDarkTheme) : null);
const installedDarkThemes = computed(() => installedThemes.value.filter(t => t.base === 'dark' || t.kind === 'dark'));
@@ -353,7 +357,7 @@ async function onDrop(ev: DragEvent) {
try {
await installTheme(code);
} catch (err) {
// nop
handleThemeInstallError(err);
}
}
}

View File

@@ -79,14 +79,15 @@ 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 '@/theme.js';
import type { Theme } from '@@/js/theme.js';
import { genId } from '@/utility/id.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 { ensureSignin } from '@/i.js';
import { addTheme, applyTheme } from '@/theme.js';
import { addTheme, themeManager } from '@/theme.js';
import { deepClone } from '@/utility/clone.js';
import * as os from '@/os.js';
import { store } from '@/store.js';
import { i18n } from '@/i18n.js';
@@ -130,7 +131,7 @@ const theme = ref<Theme>({
name: 'untitled',
author: `@${$i.username}@${toUnicode(host)}`,
base: 'light',
props: lightTheme.props,
props: deepClone(lightTheme.props),
});
const description = ref<string | null>(null);
const themeCode = ref<string>('');
@@ -170,7 +171,7 @@ function setFgColor(color: typeof fgColors[number]) {
function apply() {
themeCode.value = JSON5.stringify(theme.value, null, '\t');
applyTheme(theme.value, false);
themeManager.previewTheme(theme.value);
changed.value = true;
}
@@ -201,7 +202,7 @@ async function saveAs() {
theme.value.name = name;
if (description.value) theme.value.desc = description.value;
await addTheme(theme.value);
applyTheme(theme.value);
themeManager.updateTheme(theme.value);
if (store.s.darkMode) {
prefer.commit('darkTheme', theme.value);
} else {

View File

@@ -0,0 +1,141 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { DeckProfile } from '@/deck.js';
import { genId } from '@/utility/id.js';
import { store } from '@/store.js';
import { prefer } from '@/preferences.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { deckStore } from '@/ui/deck/deck-store.js';
import { unisonReload } from '@/utility/unison-reload.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import type { SoundStore } from '@/preferences/def.js';
// TODO: そのうち消す
export function migrateOldSettings() {
os.waiting({ text: i18n.ts.settingsMigrating });
store.loaded.then(async () => {
misskeyApi('i/registry/get', { scope: ['client'], key: 'themes' }).catch(() => []).then((themes: any) => {
if (themes.length > 0) {
prefer.commit('themes', themes);
}
});
prefer.commit('deck.profile', deckStore.s.profile);
misskeyApi('i/registry/keys', {
scope: ['client', 'deck', 'profiles'],
}).then(async keys => {
const profiles: DeckProfile[] = [];
for (const key of keys) {
const deck = await misskeyApi('i/registry/get', {
scope: ['client', 'deck', 'profiles'],
key: key,
});
profiles.push({
id: genId(),
name: key,
columns: deck.columns,
layout: deck.layout,
});
}
prefer.commit('deck.profiles', profiles);
});
prefer.commit('emojiPalettes', [{
id: 'reactions',
name: '',
emojis: store.s.reactions,
}, {
id: 'pinnedEmojis',
name: '',
emojis: store.s.pinnedEmojis,
}]);
prefer.commit('emojiPaletteForMain', 'pinnedEmojis');
prefer.commit('emojiPaletteForReaction', 'reactions');
prefer.commit('overridedDeviceKind', store.s.overridedDeviceKind);
prefer.commit('widgets', store.s.widgets);
prefer.commit('keepCw', store.s.keepCw);
prefer.commit('collapseRenotes', store.s.collapseRenotes);
prefer.commit('rememberNoteVisibility', store.s.rememberNoteVisibility);
prefer.commit('uploadFolder', store.s.uploadFolder);
prefer.commit('menu', [...store.s.menu, 'chat']);
prefer.commit('statusbars', store.s.statusbars);
prefer.commit('pinnedUserLists', store.s.pinnedUserLists);
prefer.commit('serverDisconnectedBehavior', store.s.serverDisconnectedBehavior);
prefer.commit('nsfw', store.s.nsfw);
prefer.commit('highlightSensitiveMedia', store.s.highlightSensitiveMedia);
prefer.commit('animation', store.s.animation);
prefer.commit('animatedMfm', store.s.animatedMfm);
prefer.commit('advancedMfm', store.s.advancedMfm);
prefer.commit('showReactionsCount', store.s.showReactionsCount);
prefer.commit('enableQuickAddMfmFunction', store.s.enableQuickAddMfmFunction);
prefer.commit('loadRawImages', store.s.loadRawImages);
prefer.commit('imageNewTab', store.s.imageNewTab);
prefer.commit('disableShowingAnimatedImages', store.s.disableShowingAnimatedImages);
prefer.commit('emojiStyle', store.s.emojiStyle);
prefer.commit('menuStyle', store.s.menuStyle);
prefer.commit('useBlurEffectForModal', store.s.useBlurEffectForModal);
prefer.commit('useBlurEffect', store.s.useBlurEffect);
prefer.commit('showFixedPostForm', store.s.showFixedPostForm);
prefer.commit('showFixedPostFormInChannel', store.s.showFixedPostFormInChannel);
prefer.commit('enableInfiniteScroll', store.s.enableInfiniteScroll);
prefer.commit('useReactionPickerForContextMenu', store.s.useReactionPickerForContextMenu);
prefer.commit('instanceTicker', store.s.instanceTicker);
prefer.commit('emojiPickerScale', store.s.emojiPickerScale);
prefer.commit('emojiPickerWidth', store.s.emojiPickerWidth);
prefer.commit('emojiPickerHeight', store.s.emojiPickerHeight);
prefer.commit('emojiPickerStyle', store.s.emojiPickerStyle);
prefer.commit('reportError', store.s.reportError);
prefer.commit('squareAvatars', store.s.squareAvatars);
prefer.commit('showAvatarDecorations', store.s.showAvatarDecorations);
prefer.commit('numberOfPageCache', store.s.numberOfPageCache);
prefer.commit('showNoteActionsOnlyHover', store.s.showNoteActionsOnlyHover);
prefer.commit('showClipButtonInNoteFooter', store.s.showClipButtonInNoteFooter);
prefer.commit('reactionsDisplaySize', store.s.reactionsDisplaySize);
prefer.commit('limitWidthOfReaction', store.s.limitWidthOfReaction);
prefer.commit('forceShowAds', store.s.forceShowAds);
prefer.commit('aiChanMode', store.s.aiChanMode);
prefer.commit('devMode', store.s.devMode);
prefer.commit('mediaListWithOneImageAppearance', store.s.mediaListWithOneImageAppearance);
prefer.commit('notificationPosition', store.s.notificationPosition);
prefer.commit('notificationStackAxis', store.s.notificationStackAxis);
prefer.commit('enableCondensedLine', store.s.enableCondensedLine);
prefer.commit('keepScreenOn', store.s.keepScreenOn);
prefer.commit('useGroupedNotifications', store.s.useGroupedNotifications);
prefer.commit('dataSaver', {
...prefer.s.dataSaver,
media: store.s.dataSaver.media,
avatar: store.s.dataSaver.avatar,
urlPreviewThumbnail: store.s.dataSaver.urlPreview,
code: store.s.dataSaver.code,
});
prefer.commit('enableSeasonalScreenEffect', store.s.enableSeasonalScreenEffect);
prefer.commit('enableHorizontalSwipe', store.s.enableHorizontalSwipe);
prefer.commit('useNativeUiForVideoAudioPlayer', store.s.useNativeUIForVideoAudioPlayer);
prefer.commit('keepOriginalFilename', store.s.keepOriginalFilename);
prefer.commit('alwaysConfirmFollow', store.s.alwaysConfirmFollow);
prefer.commit('confirmWhenRevealingSensitiveMedia', store.s.confirmWhenRevealingSensitiveMedia);
prefer.commit('contextMenu', store.s.contextMenu);
prefer.commit('skipNoteRender', store.s.skipNoteRender);
prefer.commit('showSoftWordMutedWord', store.s.showSoftWordMutedWord);
prefer.commit('confirmOnReact', store.s.confirmOnReact);
prefer.commit('defaultFollowWithReplies', store.s.defaultWithReplies);
prefer.commit('sound.masterVolume', store.s.sound_masterVolume);
prefer.commit('sound.notUseSound', store.s.sound_notUseSound);
prefer.commit('sound.useSoundOnlyWhenActive', store.s.sound_useSoundOnlyWhenActive);
prefer.commit('sound.on.note', store.s.sound_note as SoundStore);
prefer.commit('sound.on.noteMy', store.s.sound_noteMy as SoundStore);
prefer.commit('sound.on.notification', store.s.sound_notification as SoundStore);
prefer.commit('sound.on.reaction', store.s.sound_reaction as SoundStore);
prefer.commit('defaultNoteVisibility', store.s.defaultNoteVisibility);
prefer.commit('defaultNoteLocalOnly', store.s.defaultNoteLocalOnly);
window.setTimeout(() => {
unisonReload();
}, 10000);
});
}

View File

@@ -8,7 +8,7 @@ import { hemisphere } from '@@/js/intl-const.js';
import { DEFAULT_EMOJIS } from '@@/js/const.js';
import { prefersReducedMotion } from '@@/js/config.js';
import { definePreferences } from './manager.js';
import type { Theme } from '@/theme.js';
import type { Theme } from '@@/js/theme.js';
import type { SoundType } from '@/utility/sound.js';
import type { Plugin } from '@/plugin.js';
import type { DeviceKind } from '@/utility/device-kind.js';

View File

@@ -118,6 +118,352 @@ export const store = markRaw(new Pizzax('base', {
where: 'device',
default: true,
},
//#region TODO: そのうち消す (preferに移行済み)
defaultWithReplies: {
where: 'account',
default: false,
},
reactions: {
where: 'account',
default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'],
},
pinnedEmojis: {
where: 'account',
default: [],
},
widgets: {
where: 'account',
default: [] as {
name: string;
id: string;
place: string | null;
data: Record<string, any>;
}[],
},
overridedDeviceKind: {
where: 'device',
default: null as DeviceKind | null,
},
defaultSideView: {
where: 'device',
default: false,
},
defaultNoteVisibility: {
where: 'account',
default: 'public' as (typeof Misskey.noteVisibilities)[number],
},
defaultNoteLocalOnly: {
where: 'account',
default: false,
},
keepCw: {
where: 'account',
default: true,
},
collapseRenotes: {
where: 'account',
default: true,
},
rememberNoteVisibility: {
where: 'account',
default: false,
},
uploadFolder: {
where: 'account',
default: null as string | null,
},
keepOriginalUploading: {
where: 'account',
default: false,
},
menu: {
where: 'deviceAccount',
default: [
'notifications',
'clips',
'drive',
'followRequests',
'-',
'explore',
'announcements',
'search',
'-',
'ui',
],
},
statusbars: {
where: 'deviceAccount',
default: [] as {
name: string;
id: string;
type: string;
size: 'verySmall' | 'small' | 'medium' | 'large' | 'veryLarge';
black: boolean;
props: Record<string, any>;
}[],
},
pinnedUserLists: {
where: 'deviceAccount',
default: [] as Misskey.entities.UserList[],
},
serverDisconnectedBehavior: {
where: 'device',
default: 'quiet' as 'quiet' | 'reload' | 'dialog',
},
nsfw: {
where: 'device',
default: 'respect' as 'respect' | 'force' | 'ignore',
},
highlightSensitiveMedia: {
where: 'device',
default: false,
},
animation: {
where: 'device',
default: !prefersReducedMotion,
},
animatedMfm: {
where: 'device',
default: !prefersReducedMotion,
},
advancedMfm: {
where: 'device',
default: true,
},
showReactionsCount: {
where: 'device',
default: false,
},
enableQuickAddMfmFunction: {
where: 'device',
default: false,
},
loadRawImages: {
where: 'device',
default: false,
},
imageNewTab: {
where: 'device',
default: false,
},
disableShowingAnimatedImages: {
where: 'device',
default: prefersReducedMotion,
},
emojiStyle: {
where: 'device',
default: 'twemoji' as 'twemoji' | 'fluentEmoji' | 'native',
},
menuStyle: {
where: 'device',
default: 'auto' as 'auto' | 'popup' | 'drawer',
},
useBlurEffectForModal: {
where: 'device',
default: DEFAULT_DEVICE_KIND === 'desktop',
},
useBlurEffect: {
where: 'device',
default: DEFAULT_DEVICE_KIND === 'desktop',
},
showFixedPostForm: {
where: 'device',
default: false,
},
showFixedPostFormInChannel: {
where: 'device',
default: false,
},
enableInfiniteScroll: {
where: 'device',
default: true,
},
useReactionPickerForContextMenu: {
where: 'device',
default: false,
},
showGapBetweenNotesInTimeline: {
where: 'device',
default: false,
},
instanceTicker: {
where: 'device',
default: 'remote' as 'none' | 'remote' | 'always',
},
emojiPickerScale: {
where: 'device',
default: 1,
},
emojiPickerWidth: {
where: 'device',
default: 1,
},
emojiPickerHeight: {
where: 'device',
default: 2,
},
emojiPickerStyle: {
where: 'device',
default: 'auto' as 'auto' | 'popup' | 'drawer',
},
reportError: {
where: 'device',
default: false,
},
squareAvatars: {
where: 'device',
default: false,
},
showAvatarDecorations: {
where: 'device',
default: true,
},
numberOfPageCache: {
where: 'device',
default: 3,
},
showNoteActionsOnlyHover: {
where: 'device',
default: false,
},
showClipButtonInNoteFooter: {
where: 'device',
default: false,
},
reactionsDisplaySize: {
where: 'device',
default: 'medium' as 'small' | 'medium' | 'large',
},
limitWidthOfReaction: {
where: 'device',
default: true,
},
forceShowAds: {
where: 'device',
default: false,
},
aiChanMode: {
where: 'device',
default: false,
},
devMode: {
where: 'device',
default: false,
},
mediaListWithOneImageAppearance: {
where: 'device',
default: 'expand' as 'expand' | '16_9' | '1_1' | '2_3',
},
notificationPosition: {
where: 'device',
default: 'rightBottom' as 'leftTop' | 'leftBottom' | 'rightTop' | 'rightBottom',
},
notificationStackAxis: {
where: 'device',
default: 'horizontal' as 'vertical' | 'horizontal',
},
enableCondensedLine: {
where: 'device',
default: true,
},
keepScreenOn: {
where: 'device',
default: false,
},
useGroupedNotifications: {
where: 'device',
default: true,
},
dataSaver: {
where: 'device',
default: {
media: false,
avatar: false,
urlPreview: false,
code: false,
},
},
enableSeasonalScreenEffect: {
where: 'device',
default: false,
},
enableHorizontalSwipe: {
where: 'device',
default: true,
},
useNativeUIForVideoAudioPlayer: {
where: 'device',
default: false,
},
keepOriginalFilename: {
where: 'device',
default: true,
},
alwaysConfirmFollow: {
where: 'device',
default: true,
},
confirmWhenRevealingSensitiveMedia: {
where: 'device',
default: false,
},
contextMenu: {
where: 'device',
default: 'app' as 'app' | 'appWithShift' | 'native',
},
skipNoteRender: {
where: 'device',
default: true,
},
showSoftWordMutedWord: {
where: 'device',
default: false,
},
confirmOnReact: {
where: 'device',
default: false,
},
hemisphere: {
where: 'device',
default: hemisphere as 'N' | 'S',
},
sound_masterVolume: {
where: 'device',
default: 0.3,
},
sound_notUseSound: {
where: 'device',
default: false,
},
sound_useSoundOnlyWhenActive: {
where: 'device',
default: false,
},
sound_note: {
where: 'device',
default: { type: 'syuilo/n-aec', volume: 1 },
},
sound_noteMy: {
where: 'device',
default: { type: 'syuilo/n-cea-4va', volume: 1 },
},
sound_notification: {
where: 'device',
default: { type: 'syuilo/n-ea', volume: 1 },
},
sound_reaction: {
where: 'device',
default: { type: 'syuilo/bubble2', volume: 1 },
},
dropAndFusion: {
where: 'device',
default: {
bgmVolume: 0.25,
sfxVolume: 1,
},
},
//#endregion
}));
// TODO: 他のタブと永続化されたstateを同期

View File

@@ -6,73 +6,183 @@
// TODO: (可能な部分を)sharedに抽出して frontend-embed と共通化
import { ref, nextTick } from 'vue';
import tinycolor from 'tinycolor2';
import { EventEmitter } from 'eventemitter3';
import lightTheme from '@@/themes/_light.json5';
import darkTheme from '@@/themes/_dark.json5';
import JSON5 from 'json5';
import { version } from '@@/js/config.js';
import type { Ref } from 'vue';
import type { BundledTheme } from 'shiki/themes';
import { getBuiltinThemes, parseThemeCode, themeProps, compile } from '@@/js/theme.js';
import type { Theme, CompiledTheme } from '@@/js/theme.js';
import { deepClone } from '@/utility/clone.js';
import { globalEvents } from '@/events.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';
import { deepEqual } from '@/utility/deep-equal.js';
export type Theme = {
id: string;
name: string;
author: string;
desc?: string;
base?: 'dark' | 'light';
kind?: 'dark' | 'light'; // legacy
props: Record<string, string>;
codeHighlighter?: {
base: BundledTheme;
overrides?: Record<string, any>;
} | {
base: '_none_';
overrides: Record<string, any>;
};
type ThemeManagerEvents = {
'themeChanging': () => void;
'themeChanged': () => void;
'previewStateChanged': (isPreview: boolean) => void;
'requestUpdateThemeCache': (theme: Theme, compiled: CompiledTheme) => void;
};
export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X'));
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; }
export const getBuiltinThemes = () => Promise.all(
[
'l-light',
'l-coffee',
'l-apricot',
'l-rainy',
'l-botanical',
'l-vivid',
'l-cherry',
'l-sushi',
'l-u0',
/** 現在適用中のテーマ */
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; }
'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)),
);
/** プレビュー中かどうか */
private _isPreviewMode = false;
get isPreviewMode() { return this._isPreviewMode; }
set isPreviewMode(value: boolean) {
if (this._isPreviewMode !== value) {
this._isPreviewMode = value;
this.emit('previewStateChanged', value);
}
}
export function getBuiltinThemesRef() {
const builtinThemes = ref<Theme[]>([]);
getBuiltinThemes().then(themes => builtinThemes.value = themes);
return builtinThemes;
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 function getThemesRef(): Ref<Theme[]> {
return prefer.r.themes;
}
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;
@@ -93,163 +203,6 @@ export async function removeTheme(theme: Theme): Promise<void> {
prefer.commit('themes', themes);
}
function applyThemeInternal(theme: Theme, persist: boolean) {
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('themeCachedVersion', version);
miLocalStorage.setItem('colorScheme', colorScheme);
}
// 色計算など再度行えるようにクライアント全体に通知
globalEvents.emit('themeChanging');
}
let timeout: number | null = null;
let currentThemeId = miLocalStorage.getItem('themeId');
export function applyTheme(theme: Theme, persist = true) {
if (timeout) {
window.clearTimeout(timeout);
timeout = null;
}
if (theme.id === currentThemeId && miLocalStorage.getItem('themeCachedVersion') === version) return;
currentThemeId = theme.id;
// 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 () => {
applyThemeInternal(theme, persist);
await nextTick();
}).finished.then(() => {
window.document.documentElement.classList.remove('_themeChanging_');
globalEvents.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_');
applyThemeInternal(theme, persist);
globalEvents.emit('themeChanged');
}
} else {
applyThemeInternal(theme, persist);
globalEvents.emit('themeChanged');
}
}
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 funcTxt = parts.shift();
const argTxt = parts.shift();
if (funcTxt && argTxt) {
const func = funcTxt.substring(1);
const arg = parseFloat(argTxt);
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 = {} as Record<string, string>;
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 (_) {
throw new Error('Failed to parse theme json');
}
if (!validateTheme(theme)) {
throw new Error('This theme is invaild');
}
if (prefer.s.themes.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 != null) applyTheme(theme, false);
}
export async function installTheme(code: string): Promise<void> {
const theme = parseThemeCode(code);
if (theme == null) return;
@@ -261,3 +214,26 @@ export function clearAppliedThemeCache() {
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);
}

View File

@@ -0,0 +1,57 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root">
<span :class="$style.icon">
<i class="ti ti-info-circle"></i>
</span>
<span :class="$style.title">{{ i18n.ts.previewingTheme }}</span>
<span :class="$style.body"><button class="_textButton" style="color: var(--MI_THEME-fgOnAccent);" @click="restore">{{ i18n.ts.previewingThemeRestore }}</button> | <MkA class="_textButton" style="color: var(--MI_THEME-fgOnAccent);" to="/settings/theme">{{ i18n.ts.settings }}</MkA></span>
</div>
</template>
<script lang="ts" setup>
import { i18n } from '@/i18n.js';
import { themeManager } from '@/theme.js';
function restore() {
themeManager.clearPreview();
}
</script>
<style lang="scss" module>
.root {
--height: 24px;
font-size: 0.85em;
display: flex;
vertical-align: bottom;
width: 100%;
line-height: var(--height);
height: var(--height);
overflow: clip;
contain: strict;
background: var(--MI_THEME-accent);
color: var(--MI_THEME-fgOnAccent);
}
.icon {
margin-left: 10px;
animation: blink 2s infinite;
}
.title {
padding: 0 10px;
font-weight: bold;
}
.body {
min-width: 0;
flex: 1;
overflow: clip;
white-space: nowrap;
text-overflow: ellipsis;
}
</style>

View File

@@ -15,6 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<XReloadSuggestion v-if="shouldSuggestReload"/>
<XPreferenceRestore v-if="shouldSuggestRestoreBackup"/>
<XThemePreviewing v-if="isThemePreviewMode"/>
<XAnnouncements v-if="$i"/>
<XStatusBars/>
<div :class="$style.columnsWrapper">
@@ -94,12 +95,14 @@ import XMobileFooterMenu from '@/ui/_common_/mobile-footer-menu.vue';
import XTitlebar from '@/ui/_common_/titlebar.vue';
import XPreferenceRestore from '@/ui/_common_/PreferenceRestore.vue';
import XReloadSuggestion from '@/ui/_common_/ReloadSuggestion.vue';
import XThemePreviewing from '@/ui/_common_/ThemePreviewing.vue';
import * as os from '@/os.js';
import { $i } from '@/i.js';
import { i18n } from '@/i18n.js';
import { deviceKind } from '@/utility/device-kind.js';
import { prefer } from '@/preferences.js';
import { store } from '@/store.js';
import { isPreviewMode as isThemePreviewMode } from '@/theme.js';
import XMainColumn from '@/ui/deck/main-column.vue';
import XTlColumn from '@/ui/deck/tl-column.vue';
import XAntennaColumn from '@/ui/deck/antenna-column.vue';

View File

@@ -14,6 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div>
<XReloadSuggestion v-if="shouldSuggestReload"/>
<XPreferenceRestore v-if="shouldSuggestRestoreBackup"/>
<XThemePreviewing v-if="isThemePreviewMode"/>
<XAnnouncements v-if="$i"/>
<XStatusBars :class="$style.statusbars"/>
</div>
@@ -40,8 +41,10 @@ import type { PageMetadata } from '@/page.js';
import XMobileFooterMenu from '@/ui/_common_/mobile-footer-menu.vue';
import XPreferenceRestore from '@/ui/_common_/PreferenceRestore.vue';
import XReloadSuggestion from '@/ui/_common_/ReloadSuggestion.vue';
import XThemePreviewing from '@/ui/_common_/ThemePreviewing.vue';
import XTitlebar from '@/ui/_common_/titlebar.vue';
import XSidebar from '@/ui/_common_/navbar.vue';
import { isPreviewMode as isThemePreviewMode } from '@/theme.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { $i } from '@/i.js';

View File

@@ -44,7 +44,7 @@ export async function getTheme(mode: 'light' | 'dark', getName = false): Promise
_res.type = mode;
if (getName) {
return _res.name;
return _res.name!;
}
return _res;
}

View File

@@ -24,6 +24,7 @@ import {
import gradient from 'chartjs-plugin-gradient';
import zoomPlugin from 'chartjs-plugin-zoom';
import { MatrixController, MatrixElement } from 'chartjs-chart-matrix';
import { themeManager } from '@/theme.js';
import { store } from '@/store.js';
import 'chartjs-adapter-date-fns';
@@ -50,7 +51,7 @@ export function initChart() {
);
// フォントカラー
Chart.defaults.color = getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-fg');
Chart.defaults.color = themeManager.currentCompiledTheme!.fg;
Chart.defaults.borderColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';

View File

@@ -11,8 +11,9 @@ export function popout(path: string, w?: HTMLElement) {
url = appendQuery(url, 'zen');
if (w) {
const position = w.getBoundingClientRect();
const width = parseInt(getComputedStyle(w, '').width, 10);
const height = parseInt(getComputedStyle(w, '').height, 10);
const computedStyle = getComputedStyle(w, '');
const width = parseInt(computedStyle.width, 10);
const height = parseInt(computedStyle.height, 10);
const x = window.screenX + position.left;
const y = window.screenY + position.top;
window.open(url, url,

View File

@@ -5,8 +5,8 @@
import { genId } from '@/utility/id.js';
import type { Theme } from '@/theme.js';
import { themeProps } from '@/theme.js';
import type { Theme } from '@@/js/theme.js';
import { themeProps } from '@@/js/theme.js';
export type Default = null;
export type Color = string;

View File

@@ -0,0 +1,188 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { afterEach, assert, beforeEach, describe, test, vi } from 'vitest';
import type { Theme } from '@@/js/theme.js';
import lightTheme from '@@/themes/_light.json5';
import darkTheme from '@@/themes/_dark.json5';
import './init';
vi.mock('@/i18n.js', () => ({
i18n: {
ts: {
_theme: {
alreadyInstalled: 'already installed',
invalid: 'invalid',
},
},
},
updateI18n: vi.fn(),
}));
vi.mock('@/os.js', () => ({
alert: vi.fn(),
}));
const cloneTheme = <T>(value: T): T => structuredClone(value);
const createTheme = (base: 'light' | 'dark', options: {
id: string;
name: string;
accent: string;
bg: string;
fg: string;
}): Theme => {
const builtin = base === 'dark' ? darkTheme : lightTheme;
return {
id: options.id,
name: options.name,
author: 'tester',
base,
props: {
...cloneTheme(builtin.props),
accent: options.accent,
bg: options.bg,
fg: options.fg,
},
};
};
const primaryTheme = createTheme('light', {
id: 'primary-theme',
name: 'Primary Theme',
accent: '#224488',
bg: '#faf7f2',
fg: '#1a1a1a',
});
const previewTheme = createTheme('dark', {
id: 'preview-theme',
name: 'Preview Theme',
accent: '#55aa33',
bg: '#101820',
fg: '#f4f4f4',
});
const replacementTheme = createTheme('dark', {
id: 'replacement-theme',
name: 'Replacement Theme',
accent: '#bb5500',
bg: '#18110f',
fg: '#f6e7df',
});
const loadThemeModule = async () => {
vi.resetModules();
return await import('@/theme.js');
};
const resetDocument = () => {
window.localStorage.clear();
document.head.innerHTML = '<meta name="theme-color" content="#000000">';
document.documentElement.className = '';
document.documentElement.removeAttribute('data-color-scheme');
document.documentElement.style.cssText = '';
Reflect.deleteProperty(document, 'startViewTransition');
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'visible',
});
};
describe('ThemeManager', () => {
beforeEach(() => {
resetDocument();
});
afterEach(() => {
window.localStorage.clear();
});
test('通常テーマ適用後のプレビューは現在テーマのみを切り替え、キャッシュは保持する', async () => {
const { themeManager, isPreviewMode } = await loadThemeModule();
themeManager.updateTheme(primaryTheme);
const cachedTheme = window.localStorage.getItem('theme');
const cachedThemeId = window.localStorage.getItem('themeId');
themeManager.previewTheme(previewTheme);
assert.strictEqual(themeManager.theme?.id, primaryTheme.id);
assert.strictEqual(themeManager.currentTheme?.id, previewTheme.id);
assert.strictEqual(themeManager.currentThemeId, previewTheme.id);
assert.strictEqual(themeManager.isPreviewMode, true);
assert.strictEqual(isPreviewMode.value, true);
assert.strictEqual(document.documentElement.dataset.colorScheme, 'dark');
assert.strictEqual(document.documentElement.style.getPropertyValue('--MI_THEME-accent'), themeManager.currentCompiledTheme?.accent);
assert.strictEqual(window.localStorage.getItem('theme'), cachedTheme);
assert.strictEqual(window.localStorage.getItem('themeId'), cachedThemeId);
});
test('プレビュー解除で元のテーマと DOM 状態が復元される', async () => {
const { themeManager, isPreviewMode } = await loadThemeModule();
themeManager.updateTheme(primaryTheme);
const originalCompiledThemeColor = themeManager.currentCompiledTheme?.htmlThemeColor;
themeManager.previewTheme(previewTheme);
const previewCompiledThemeColor = themeManager.currentCompiledTheme?.htmlThemeColor;
assert.strictEqual(themeManager.currentTheme?.id, previewTheme.id);
assert.notStrictEqual(previewCompiledThemeColor, originalCompiledThemeColor);
themeManager.clearPreview();
assert.strictEqual(themeManager.theme?.id, primaryTheme.id);
assert.strictEqual(themeManager.currentTheme?.id, primaryTheme.id);
assert.strictEqual(themeManager.currentCompiledTheme?.htmlThemeColor, originalCompiledThemeColor);
assert.strictEqual(themeManager.isPreviewMode, false);
assert.strictEqual(isPreviewMode.value, false);
assert.strictEqual(document.documentElement.dataset.colorScheme, 'light');
assert.strictEqual(document.documentElement.style.getPropertyValue('--MI_THEME-accent'), themeManager.currentCompiledTheme?.accent);
assert.strictEqual(document.head.querySelector('meta[name="theme-color"]')?.getAttribute('content'), originalCompiledThemeColor);
assert.strictEqual(window.localStorage.getItem('themeId'), primaryTheme.id);
});
test('プレビュー中に通常テーマを更新するとプレビューを抜けて新しい通常テーマが適用される', async () => {
const { themeManager, isPreviewMode } = await loadThemeModule();
themeManager.updateTheme(primaryTheme);
themeManager.previewTheme(previewTheme);
themeManager.updateTheme(replacementTheme);
assert.strictEqual(themeManager.theme?.id, replacementTheme.id);
assert.strictEqual(themeManager.currentTheme?.id, replacementTheme.id);
assert.strictEqual(themeManager.isPreviewMode, false);
assert.strictEqual(isPreviewMode.value, false);
assert.strictEqual(document.documentElement.dataset.colorScheme, 'dark');
assert.strictEqual(document.documentElement.style.getPropertyValue('--MI_THEME-accent'), themeManager.currentCompiledTheme?.accent);
assert.strictEqual(window.localStorage.getItem('themeId'), replacementTheme.id);
});
test('themeChanging と themeChanged はプレビュー適用と復帰のたびに発火する', async () => {
const { themeManager } = await loadThemeModule();
const events: string[] = [];
themeManager.on('themeChanging', () => {
events.push('themeChanging');
});
themeManager.on('themeChanged', () => {
events.push('themeChanged');
});
themeManager.updateTheme(primaryTheme);
themeManager.previewTheme(previewTheme);
themeManager.clearPreview();
assert.deepStrictEqual(events, [
'themeChanging',
'themeChanged',
'themeChanging',
'themeChanged',
'themeChanging',
'themeChanged',
]);
});
});

View File

@@ -23,7 +23,8 @@
"resolveJsonModule": true,
"isolatedModules": true,
"paths": {
"@/*": ["../src/*"]
"@/*": ["../src/*"],
"@@/*": ["../../frontend-shared/*"]
},
"typeRoots": [
"../node_modules/@types"
@@ -37,6 +38,7 @@
"compileOnSave": false,
"include": [
"./**/*.ts",
"../src/**/*.vue"
"../src/**/*.vue",
"../@types/**/*.d.ts"
]
}

View File

@@ -5444,6 +5444,14 @@ export interface Locale extends ILocale {
* メッセージ
*/
"directMessage_short": string;
/**
* 旧設定情報を移行
*/
"migrateOldSettings": string;
/**
* 通常これは自動で行われていますが、何らかの理由により上手く移行されなかった場合は手動で移行処理をトリガーできます。現在の設定情報は上書きされます。
*/
"migrateOldSettings_description": string;
/**
* 圧縮
*/
@@ -5464,6 +5472,10 @@ export interface Locale extends ILocale {
* 埋め込み
*/
"embed": string;
/**
* 設定を移行しています。しばらくお待ちください... (後ほど、設定→その他→旧設定情報を移行 で手動で移行することもできます)
*/
"settingsMigrating": string;
/**
* 読み取り専用
*/
@@ -5639,6 +5651,14 @@ export interface Locale extends ILocale {
* リノート先のチャンネルを見る
*/
"viewRenotedChannel": string;
/**
* テーマのプレビュー中
*/
"previewingTheme": string;
/**
* 元に戻す
*/
"previewingThemeRestore": string;
"_imageEditing": {
"_vars": {
/**

18
pnpm-lock.yaml generated
View File

@@ -1169,9 +1169,18 @@ importers:
i18n:
specifier: workspace:*
version: link:../i18n
json5:
specifier: 2.2.3
version: 2.2.3
misskey-js:
specifier: workspace:*
version: link:../misskey-js
shiki:
specifier: 4.0.2
version: 4.0.2
tinycolor2:
specifier: 1.6.0
version: 1.6.0
vue:
specifier: 3.5.33
version: 3.5.33(typescript@5.9.3)
@@ -1179,21 +1188,18 @@ importers:
'@types/node':
specifier: 24.12.2
version: 24.12.2
'@types/tinycolor2':
specifier: 1.4.6
version: 1.4.6
'@typescript-eslint/eslint-plugin':
specifier: 8.59.0
version: 8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)
'@typescript-eslint/parser':
specifier: 8.59.0
version: 8.59.0(eslint@9.39.4)(typescript@5.9.3)
esbuild:
specifier: 0.28.0
version: 0.28.0
eslint-plugin-vue:
specifier: 10.9.0
version: 10.9.0(@stylistic/eslint-plugin@5.5.0(eslint@9.39.4))(@typescript-eslint/parser@8.59.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(vue-eslint-parser@10.4.0(eslint@9.39.4))
nodemon:
specifier: 3.1.14
version: 3.1.14
vue-eslint-parser:
specifier: 10.4.0
version: 10.4.0(eslint@9.39.4)

View File

@@ -13,7 +13,6 @@ const __dirname = import.meta.dirname;
fs.rmSync(__dirname + '/../packages/backend/src-js', { recursive: true, force: true });
fs.rmSync(__dirname + '/../packages/backend/node_modules', { recursive: true, force: true });
fs.rmSync(__dirname + '/../packages/frontend-shared/built', { recursive: true, force: true });
fs.rmSync(__dirname + '/../packages/frontend-shared/node_modules', { recursive: true, force: true });
fs.rmSync(__dirname + '/../packages/frontend-builder/node_modules', { recursive: true, force: true });

View File

@@ -10,7 +10,6 @@ const __dirname = import.meta.dirname;
(async () => {
fs.rmSync(__dirname + '/../packages/backend/built', { recursive: true, force: true });
fs.rmSync(__dirname + '/../packages/backend/src-js', { recursive: true, force: true });
fs.rmSync(__dirname + '/../packages/frontend-shared/built', { recursive: true, force: true });
fs.rmSync(__dirname + '/../packages/frontend/built', { recursive: true, force: true });
fs.rmSync(__dirname + '/../packages/frontend-embed/built', { recursive: true, force: true });
fs.rmSync(__dirname + '/../packages/icons-subsetter/built', { recursive: true, force: true });

View File

@@ -70,12 +70,6 @@ execa('pnpm', ['--filter', 'backend', 'dev'], {
stderr: process.stderr,
});
execa('pnpm', ['--filter', 'frontend-shared', 'watch', '--no-clean'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
});
execa('pnpm', ['--filter', 'frontend', 'watch'], {
cwd: _dirname + '/../',
stdout: process.stdout,