mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-05-07 08:45:33 +02:00
Compare commits
2 Commits
clean-pref
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b73ac26612 | ||
|
|
b528ff9c59 |
@@ -4,8 +4,10 @@
|
||||
-
|
||||
|
||||
### Client
|
||||
- 2025.4.0 以前の設定情報の移行処理が削除されました
|
||||
- 2025.4.0 から直接 2026.6.0 以上にアップデートする場合は設定が移行されませんので注意してください。移行したい場合は一度 2026.5.1 を経由してください。
|
||||
- Enhance: テーマのプレビュー時、リロードせずにもとのテーマに戻せるように
|
||||
- Fix: テーマエディター使用時に、最初の変更のみ適用される問題を修正
|
||||
- Fix: テーマのプレビュー時、既存のテーマとIDが被っている場合にプレビューできない問題を修正
|
||||
- Fix: テーマのインストールエラーの表示を改善
|
||||
|
||||
### Server
|
||||
-
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
13
packages/frontend-shared/@types/theme.d.ts
vendored
Normal file
13
packages/frontend-shared/@types/theme.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@@ -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.`);
|
||||
});
|
||||
}
|
||||
126
packages/frontend-shared/js/theme.ts
Normal file
126
packages/frontend-shared/js/theme.ts
Normal 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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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)';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
141
packages/frontend/src/pref-migrate.ts
Normal file
141
packages/frontend/src/pref-migrate.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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を同期
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
57
packages/frontend/src/ui/_common_/ThemePreviewing.vue
Normal file
57
packages/frontend/src/ui/_common_/ThemePreviewing.vue
Normal 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>
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
188
packages/frontend/test/theme.test.ts
Normal file
188
packages/frontend/test/theme.test.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
18
pnpm-lock.yaml
generated
@@ -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)
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user