diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index ab4b10549c..8ae0628471 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -519,6 +519,10 @@ fixedWidgetsPosition: "ウィジェットの位置を固定する" enablePlayer: "プレイヤーを開く" disablePlayer: "プレイヤーを閉じる" expandTweet: "ツイートを展開する" +themeEditor: "テーマエディター" +description: "説明" +author: "作者" +leaveConfirm: "未保存の変更があります。破棄しますか?" deck: "デッキ" undeck: "デッキ解除" @@ -530,6 +534,70 @@ _theme: installed: "{name}をインストールしました" alreadyInstalled: "そのテーマは既にインストールされています" invalid: "テーマの形式が間違っています" + make: "テーマを作る" + base: "ベース" + addConstant: "定数を追加" + constant: "定数" + defaultValue: "デフォルト値" + color: "色" + refProp: "プロパティを参照" + refConst: "定数を参照" + key: "キー" + func: "関数" + funcKind: "関数の種類" + argument: "引数" + basedProp: "元にするプロパティの名前" + alpha: "不透明度" + darken: "暗さ" + lighten: "明るさ" + inputConstantName: "定数名を入力してください" + importInfo: "ここにテーマコードを貼り付けて、エディターにインポートできます" + deleteConstantConfirm: "定数 {const} を削除しても良いですか?" + + keys: + accent: "アクセント" + bg: "背景" + fg: "文字" + focus: "フォーカス" + indicator: "インジケーター" + panel: "パネル" + shadow: "影" + header: "ヘッダー" + navBg: "サイドバーの背景" + navFg: "サイドバーの文字" + navHoverFg: "サイドバー文字(ホバー)" + navActive: "サイドバー文字(アクティブ)" + navIndicator: "サイドバーのインジケーター" + link: "リンク" + hashtag: "ハッシュタグ" + mention: "メンション" + mentionMe: "あなた宛てメンション" + renote: "Renote" + modalBg: "モーダルの背景" + divider: "分割線" + scrollbarHandle: "スクロールバーの取っ手" + scrollbarHandleHover: "スクロールバーの取っ手(ホバー)" + dateLabelFg: "日付ラベルの文字" + infoBg: "情報の背景" + infoFg: "情報の文字" + infoWarnBg: "警告の背景" + infoWarnFg: "警告の文字" + cwBg: "CW ボタンの背景" + cwFg: "CW ボタンの文字" + cwHoverBg: "CW ボタンの背景 (ホバー)" + toastBg: "通知トーストの背景" + toastFg: "通知トーストの文字" + buttonBg: "ボタンの背景" + buttonHoverBg: "ボタンの背景 (ホバー)" + inputBorder: "入力ボックスの縁取り" + listItemHoverBg: "リスト項目の背景 (ホバー)" + driveFolderBg: "ドライブフォルダーの背景" + wallpaperOverlay: "壁紙のオーバーレイ" + badge: "バッジ" + messageBg: "チャットの背景" + accentDarken: "アクセント (暗め)" + accentLighten: "アクセント (明るめ)" + fgHighlighted: "強調された文字" _sfx: note: "ノート" diff --git a/src/client/app.vue b/src/client/app.vue index 4f39183564..a751d5db44 100644 --- a/src/client/app.vue +++ b/src/client/app.vue @@ -131,6 +131,10 @@ export default Vue.extend({ computed: { keymap(): any { return { + 'd': () => { + if (this.$store.state.device.syncDeviceDarkMode) return; + this.$store.commit('device/set', { key: 'darkMode', value: !this.$store.state.device.darkMode }); + }, 'p': this.post, 'n': this.post, 's': this.search, diff --git a/src/client/pages/preferences/theme.vue b/src/client/pages/preferences/theme.vue index 246787fa58..173ccd7091 100644 --- a/src/client/pages/preferences/theme.vue +++ b/src/client/pages/preferences/theme.vue @@ -22,6 +22,7 @@ </label> </div> </div> + <mk-switch v-model="syncDeviceDarkMode">{{ $t('syncDeviceDarkMode') }}</mk-switch> </div> <div class="_content"> <mk-select v-model="lightTheme"> @@ -42,10 +43,7 @@ <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> </optgroup> </mk-select> - <a href="https://assets.msky.cafe/theme/list" rel="noopener" target="_blank" class="_link">{{ $t('_theme.explore') }}</a> - </div> - <div class="_content"> - <mk-switch v-model="syncDeviceDarkMode">{{ $t('syncDeviceDarkMode') }}</mk-switch> + <a href="https://assets.msky.cafe/theme/list" rel="noopener" target="_blank" class="_link">{{ $t('_theme.explore') }}</a>・<router-link to="/theme-editor" class="_link">{{ $t('_theme.make') }}</router-link> </div> <div class="_content"> <mk-button primary v-if="wallpaper == null" @click="setWallpaper">{{ $t('setWallpaper') }}</mk-button> diff --git a/src/client/pages/room/room.vue b/src/client/pages/room/room.vue index cf6850526f..05b93c04e8 100644 --- a/src/client/pages/room/room.vue +++ b/src/client/pages/room/room.vue @@ -143,7 +143,7 @@ export default Vue.extend({ if (this.changed) { this.$root.dialog({ type: 'warning', - text: this.$t('leave-confirm'), + text: this.$t('leaveConfirm'), showCancelButton: true }).then(({ canceled }) => { if (canceled) { diff --git a/src/client/pages/theme-editor.vue b/src/client/pages/theme-editor.vue new file mode 100644 index 0000000000..3a3fbfa2d7 --- /dev/null +++ b/src/client/pages/theme-editor.vue @@ -0,0 +1,343 @@ +<template> +<div class="t9makv94"> + <portal to="icon"><fa :icon="faPalette"/></portal> + <portal to="title">{{ $t('themeEditor') }}</portal> + + <section class="_card"> + <div class="_content"> + <mk-input v-model="name" required><span>{{ $t('name') }}</span></mk-input> + <mk-input v-model="author" required><span>{{ $t('author') }}</span></mk-input> + <mk-textarea v-model="description"><span>{{ $t('description') }}</span></mk-textarea> + <div class="_inputs"> + <div v-text="$t('_theme.baseTheme')" /> + <mk-radio v-model="baseTheme" value="light">{{ $t('light') }}</mk-radio> + <mk-radio v-model="baseTheme" value="dark">{{ $t('dark') }}</mk-radio> + </div> + </div> + <div class="_content"> + <div class="list-view"> + <div class="item" v-for="([ k, v ], i) in theme" :key="k"> + <div class="_inputs"> + <div> + {{ k.startsWith('$') ? `${k} (${$t('_theme.constant')})` : $t('_theme.keys.' + k) }} + <button v-if="k.startsWith('$')" class="_button _link" @click="del(i)" v-text="$t('delete')" /> + </div> + <div> + <div class="type" @click="chooseType($event, i)"> + {{ getTypeOf(v) }} <fa :icon="faChevronDown"/> + </div> + <!-- default --> + <div v-if="v === null" v-text="baseProps[k]" class="default-value" /> + <!-- color --> + <div v-else-if="typeof v === 'string'" class="color"> + <input type="color" :value="v" @input="colorChanged($event.target.value, i)"/> + <mk-input class="select" :value="v" @input="colorChanged($event, i)"/> + </div> + <!-- ref const --> + <mk-input v-else-if="v.type === 'refConst'" v-model="v.key"> + <template #prefix>$</template> + <span>{{ $t('name') }}</span> + </mk-input> + <!-- ref props --> + <mk-select class="select" v-else-if="v.type === 'refProp'" v-model="v.key"> + <option v-for="key in themeProps" :value="key" :key="key">{{ $t('_theme.keys.' + key) }}</option> + </mk-select> + <!-- func --> + <template v-else-if="v.type === 'func'"> + <mk-select class="select" v-model="v.name"> + <template #label>{{ $t('_theme.funcKind') }}</template> + <option v-for="n in ['alpha', 'darken', 'lighten']" :value="n" :key="n">{{ $t('_theme.' + n) }}</option> + </mk-select> + <mk-input type="number" v-model="v.arg"><span>{{ $t('_theme.argument') }}</span></mk-input> + <mk-select class="select" v-model="v.value"> + <template #label>{{ $t('_theme.basedProp') }}</template> + <option v-for="key in themeProps" :value="key" :key="key">{{ $t('_theme.keys.' + key) }}</option> + </mk-select> + </template> + </div> + </div> + </div> + <mk-button primary @click="addConst">{{ $t('_theme.addConstant') }}</mk-button> + </div> + </div> + <div class="_content"> + <mk-textarea v-model="themeToImport"> + {{ $t('_theme.importInfo') }} + </mk-textarea> + <mk-button :disabled="!themeToImport.trim()" @click="importTheme">{{ $t('import') }}</mk-button> + </div> + <div class="_footer"> + <mk-button inline @click="preview">{{ $t('preview') }}</mk-button> + <mk-button inline primary :disabled="!name || !author" @click="save">{{ $t('save') }}</mk-button> + </div> + </section> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faPalette, faChevronDown, faKeyboard } from '@fortawesome/free-solid-svg-icons'; +import * as JSON5 from 'json5'; + +import MkRadio from '../components/ui/radio.vue'; +import MkButton from '../components/ui/button.vue'; +import MkInput from '../components/ui/input.vue'; +import MkTextarea from '../components/ui/textarea.vue'; +import MkSelect from '../components/ui/select.vue'; + +import { convertToMisskeyTheme, ThemeValue, convertToViewModel, ThemeViewModel } from '../scripts/theme-editor'; +import { Theme, applyTheme, lightTheme, darkTheme, themeProps, validateTheme } from '../scripts/theme'; +import { toUnicode } from 'punycode'; +import { host } from '../config'; + +export default Vue.extend({ + components: { + MkRadio, + MkButton, + MkInput, + MkTextarea, + MkSelect + }, + metaInfo() { + return { + title: this.$t('themeEditor') + (this.changed ? '*' : '') + }; + }, + + data() { + return { + theme: [] as ThemeViewModel, + name: '', + description: '', + baseTheme: 'light' as 'dark' | 'light', + author: `@${this.$store.state.i.username}@${toUnicode(host)}`, + themeToImport: '', + changed: false, + faPalette, faChevronDown, faKeyboard, + lightTheme, darkTheme, themeProps, + } + }, + + computed: { + baseProps() { + return this.baseTheme === 'light' ? this.lightTheme.props : this.darkTheme.props; + }, + }, + + beforeDestroy() { + window.removeEventListener('beforeunload', this.beforeunload); + }, + + async beforeRouteLeave(to, from, next) { + if (this.changed && !(await this.confirm())) { + next(false); + } else { + next(); + } + }, + + mounted() { + this.init(); + window.addEventListener('beforeunload', this.beforeunload); + const changed = () => this.changed = true; + this.$watch('name', changed); + this.$watch('description', changed); + this.$watch('baseTheme', changed); + this.$watch('author', changed); + this.$watch('theme', changed); + }, + + methods: { + beforeunload(e: BeforeUnloadEvent) { + if (this.changed) { + e.preventDefault(); + e.returnValue = ''; + } + }, + + async confirm(): Promise<boolean> { + const { canceled } = await this.$root.dialog({ + type: 'warning', + text: this.$t('leaveConfirm'), + showCancelButton: true + }); + return !canceled; + }, + + init() { + const t: ThemeViewModel = []; + for (const key of themeProps) { + t.push([ key, null ]); + } + this.theme = t; + }, + + async del(i: number) { + const { canceled } = await this.$root.dialog({ + type: 'warning', + showCancelButton: true, + text: this.$t('_theme.deleteConstantConfirm', { const: this.theme[i][0] }), + }); + if (canceled) return; + Vue.delete(this.theme, i); + }, + + async addConst() { + const { canceled, result } = await this.$root.dialog({ + title: this.$t('_theme.inputConstantName'), + input: true + }); + if (canceled) return; + this.theme.push([ '$' + result, '#000000']); + }, + + save() { + const theme = convertToMisskeyTheme(this.theme, this.name, this.description, this.author, this.baseTheme); + const themes = this.$store.state.device.themes.concat(theme); + this.$store.commit('device/set', { + key: 'themes', value: themes + }); + this.$root.dialog({ + type: 'success', + text: this.$t('_theme.installed', { name: theme.name }) + }); + this.changed = false; + }, + + preview() { + const theme = convertToMisskeyTheme(this.theme, this.name, this.description, this.author, this.baseTheme); + try { + applyTheme(theme, false); + } catch (e) { + this.$root.dialog({ + type: 'error', + text: e.message + }); + } + }, + + async importTheme() { + if (this.changed && (!await this.confirm())) return; + + try { + const theme = JSON5.parse(this.themeToImport) as Theme; + if (!validateTheme(theme)) throw new Error(this.$t('_theme.invalid')); + + this.name = theme.name; + this.description = theme.desc || ''; + this.author = theme.author; + this.baseTheme = theme.base || 'light'; + this.theme = convertToViewModel(theme); + this.themeToImport = ''; + } catch (e) { + this.$root.dialog({ + type: 'error', + text: e.message + }); + } + }, + + colorChanged(color: string, i: number) { + Vue.set(this.theme, i, [this.theme[i][0], color]); + }, + + getTypeOf(v: ThemeValue) { + return v === null + ? this.$t('_theme.defaultValue') + : typeof v === 'string' + ? this.$t('_theme.color') + : this.$t('_theme.' + v.type); + }, + + async chooseType(e: MouseEvent, i: number) { + const newValue = await this.showTypeMenu(e); + Vue.set(this.theme, i, [ this.theme[i][0], newValue ]); + }, + + showTypeMenu(e: MouseEvent) { + return new Promise<ThemeValue>((resolve) => { + this.$root.menu({ + items: [{ + text: this.$t('_theme.defaultValue'), + action: () => resolve(null), + }, { + text: this.$t('_theme.color'), + action: () => resolve('#000000'), + }, { + text: this.$t('_theme.func'), + action: () => resolve({ + type: 'func', name: 'alpha', arg: 1, value: 'accent' + }), + }, { + text: this.$t('_theme.refProp'), + action: () => resolve({ + type: 'refProp', key: 'accent', + }), + }, { + text: this.$t('_theme.refConst'), + action: () => resolve({ + type: 'refConst', key: '', + }), + },], + source: e.currentTarget || e.target, + }); + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.t9makv94 { + > ._card { + > ._content { + > .list-view { + height: 480px; + overflow: auto; + border: 1px solid var(--divider); + + > .item { + min-height: 48px; + padding: 0 16px; + word-break: break-all; + + &:not(:last-child) { + padding-bottom: 8px; + } + + .select { + margin: 24px 0; + } + + .type { + cursor: pointer; + } + + .default-value { + opacity: 0.6; + pointer-events: none; + user-select: none; + } + + .color { + > input { + display: inline-block; + width: 1.5em; + height: 1.5em; + } + + > div { + margin-left: 8px; + display: inline-block; + } + } + } + + > ._button { + margin: 16px; + } + } + } + } +} +</style> diff --git a/src/client/router.ts b/src/client/router.ts index cf98c57bd7..a741aeb955 100644 --- a/src/client/router.ts +++ b/src/client/router.ts @@ -24,6 +24,7 @@ export const router = new VueRouter({ { path: '/about-misskey', component: page('about-misskey') }, { path: '/featured', component: page('featured') }, { path: '/docs', component: page('docs') }, + { path: '/theme-editor', component: page('theme-editor') }, { path: '/docs/:doc', component: page('doc'), props: true }, { path: '/explore', component: page('explore') }, { path: '/explore/tags/:tag', props: true, component: page('explore') }, diff --git a/src/client/scripts/theme-editor.ts b/src/client/scripts/theme-editor.ts new file mode 100644 index 0000000000..e0c3bc25bc --- /dev/null +++ b/src/client/scripts/theme-editor.ts @@ -0,0 +1,74 @@ +import { v4 as uuid} from 'uuid'; + +import { themeProps, Theme } from './theme'; + +export type Default = null; +export type Color = string; +export type FuncName = 'alpha' | 'darken' | 'lighten'; +export type Func = { type: 'func', name: FuncName, arg: number, value: string }; +export type RefProp = { type: 'refProp', key: string }; +export type RefConst = { type: 'refConst', key: string }; + +export type ThemeValue = Color | Func | RefProp | RefConst | Default; + +export type ThemeViewModel = [ string, ThemeValue ][]; + +export const fromThemeString = (str?: string) : ThemeValue => { + if (!str) return null; + if (str.startsWith(':')) { + const parts = str.slice(1).split('<'); + const name = parts[0] as FuncName; + const arg = parseFloat(parts[1]); + const value = parts[2].startsWith('@') ? parts[2].slice(1) : ''; + return { type: 'func', name, arg, value }; + } else if (str.startsWith('@')) { + return { + type: 'refProp', + key: str.slice(1), + }; + } else if (str.startsWith('$')) { + return { + type: 'refConst', + key: str.slice(1), + }; + } else { + return str; + } +}; + +export const toThemeString = (value: Color | Func | RefProp | RefConst) => { + if (typeof value === 'string') return value; + switch (value.type) { + case 'func': return `:${value.name}<${value.arg}<@${value.value}`; + case 'refProp': return `@${value.key}`; + case 'refConst': return `$${value.key}`; + } +}; + +export const convertToMisskeyTheme = (vm: ThemeViewModel, name: string, desc: string, author: string, base: 'dark' | 'light'): Theme => { + const props = { } as { [key: string]: string }; + for (const [ key, value ] of vm) { + if (value === null) continue; + props[key] = toThemeString(value); + } + + return { + id: uuid(), + name, desc, author, props, base + }; +}; + +export const convertToViewModel = (theme: Theme): ThemeViewModel => { + const vm: ThemeViewModel = []; + // プロパティの登録 + vm.push(...themeProps.map(key => [ key, fromThemeString(theme.props[key])] as [ string, ThemeValue ])); + + // 定数の登録 + const consts = Object + .keys(theme.props) + .filter(k => k.startsWith('$')) + .map(k => [ k, fromThemeString(theme.props[k]) ] as [ string, ThemeValue ]); + + vm.push(...consts); + return vm; +}; diff --git a/src/client/scripts/theme.ts b/src/client/scripts/theme.ts index d458df45f0..30eaf77e01 100644 --- a/src/client/scripts/theme.ts +++ b/src/client/scripts/theme.ts @@ -12,6 +12,8 @@ export type Theme = { export const lightTheme: Theme = require('../themes/_light.json5'); export const darkTheme: Theme = require('../themes/_dark.json5'); +export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X')); + export const builtinThemes = [ require('../themes/white.json5'), require('../themes/black.json5'), diff --git a/src/client/themes/_dark.json5 b/src/client/themes/_dark.json5 index 4e5225db36..8d7a345fd5 100644 --- a/src/client/themes/_dark.json5 +++ b/src/client/themes/_dark.json5 @@ -12,12 +12,10 @@ accent: '#86b300', accentDarken: ':darken<10<@accent', accentLighten: ':lighten<10<@accent', - accentShadow: ':alpha<0.3<@accent', focus: ':alpha<0.3<@accent', bg: '#000', fg: '#c7d1d8', fgHighlighted: ':lighten<3<@fg', - html: '@bg', divider: 'rgba(255, 255, 255, 0.1)', indicator: '@accent', panel: '#000', diff --git a/src/client/themes/_light.json5 b/src/client/themes/_light.json5 index 2317ddef65..4e499e178c 100644 --- a/src/client/themes/_light.json5 +++ b/src/client/themes/_light.json5 @@ -12,12 +12,10 @@ accent: '#86b300', accentDarken: ':darken<10<@accent', accentLighten: ':lighten<10<@accent', - accentShadow: ':alpha<0.4<@accent', focus: ':alpha<0.3<@accent', bg: '#fafafa', fg: '#5c6a73', fgHighlighted: ':darken<3<@fg', - html: '@bg', divider: 'rgba(0, 0, 0, 0.1)', indicator: '@accent', panel: '#fff', diff --git a/src/client/themes/halloween.json5 b/src/client/themes/halloween.json5 index 7cabf01d1e..1394c793ed 100644 --- a/src/client/themes/halloween.json5 +++ b/src/client/themes/halloween.json5 @@ -12,7 +12,6 @@ panel: '#1f1d30', bg: '#0f0e17', fg: '#b1bee3', - html: '@accent', renote: '@accent', }, }