テーマエディターの実装 (#6482)

* テーマ機能の実装

* resolve #6478

* 定数を削除できるように

* 変更を破棄するか確認ダイアログを表示するように

* fix code

* Update theme.ts

* ✌️

* fix path

* wip

* wip

* wip

Co-authored-by: syuilo <syuilotan@yahoo.co.jp>
This commit is contained in:
Xeltica 2020-07-11 12:12:35 +09:00 committed by GitHub
parent cf3fc97202
commit 80bebea9e6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 495 additions and 10 deletions

View file

@ -519,6 +519,10 @@ fixedWidgetsPosition: "ウィジェットの位置を固定する"
enablePlayer: "プレイヤーを開く" enablePlayer: "プレイヤーを開く"
disablePlayer: "プレイヤーを閉じる" disablePlayer: "プレイヤーを閉じる"
expandTweet: "ツイートを展開する" expandTweet: "ツイートを展開する"
themeEditor: "テーマエディター"
description: "説明"
author: "作者"
leaveConfirm: "未保存の変更があります。破棄しますか?"
deck: "デッキ" deck: "デッキ"
undeck: "デッキ解除" undeck: "デッキ解除"
@ -530,6 +534,70 @@ _theme:
installed: "{name}をインストールしました" installed: "{name}をインストールしました"
alreadyInstalled: "そのテーマは既にインストールされています" alreadyInstalled: "そのテーマは既にインストールされています"
invalid: "テーマの形式が間違っています" 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: _sfx:
note: "ノート" note: "ノート"

View file

@ -131,6 +131,10 @@ export default Vue.extend({
computed: { computed: {
keymap(): any { keymap(): any {
return { 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, 'p': this.post,
'n': this.post, 'n': this.post,
's': this.search, 's': this.search,

View file

@ -22,6 +22,7 @@
</label> </label>
</div> </div>
</div> </div>
<mk-switch v-model="syncDeviceDarkMode">{{ $t('syncDeviceDarkMode') }}</mk-switch>
</div> </div>
<div class="_content"> <div class="_content">
<mk-select v-model="lightTheme"> <mk-select v-model="lightTheme">
@ -42,10 +43,7 @@
<option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
</optgroup> </optgroup>
</mk-select> </mk-select>
<a href="https://assets.msky.cafe/theme/list" rel="noopener" target="_blank" class="_link">{{ $t('_theme.explore') }}</a> <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-switch v-model="syncDeviceDarkMode">{{ $t('syncDeviceDarkMode') }}</mk-switch>
</div> </div>
<div class="_content"> <div class="_content">
<mk-button primary v-if="wallpaper == null" @click="setWallpaper">{{ $t('setWallpaper') }}</mk-button> <mk-button primary v-if="wallpaper == null" @click="setWallpaper">{{ $t('setWallpaper') }}</mk-button>

View file

@ -143,7 +143,7 @@ export default Vue.extend({
if (this.changed) { if (this.changed) {
this.$root.dialog({ this.$root.dialog({
type: 'warning', type: 'warning',
text: this.$t('leave-confirm'), text: this.$t('leaveConfirm'),
showCancelButton: true showCancelButton: true
}).then(({ canceled }) => { }).then(({ canceled }) => {
if (canceled) { if (canceled) {

View file

@ -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>

View file

@ -24,6 +24,7 @@ export const router = new VueRouter({
{ path: '/about-misskey', component: page('about-misskey') }, { path: '/about-misskey', component: page('about-misskey') },
{ path: '/featured', component: page('featured') }, { path: '/featured', component: page('featured') },
{ path: '/docs', component: page('docs') }, { path: '/docs', component: page('docs') },
{ path: '/theme-editor', component: page('theme-editor') },
{ path: '/docs/:doc', component: page('doc'), props: true }, { path: '/docs/:doc', component: page('doc'), props: true },
{ path: '/explore', component: page('explore') }, { path: '/explore', component: page('explore') },
{ path: '/explore/tags/:tag', props: true, component: page('explore') }, { path: '/explore/tags/:tag', props: true, component: page('explore') },

View file

@ -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;
};

View file

@ -12,6 +12,8 @@ export type Theme = {
export const lightTheme: Theme = require('../themes/_light.json5'); export const lightTheme: Theme = require('../themes/_light.json5');
export const darkTheme: Theme = require('../themes/_dark.json5'); export const darkTheme: Theme = require('../themes/_dark.json5');
export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X'));
export const builtinThemes = [ export const builtinThemes = [
require('../themes/white.json5'), require('../themes/white.json5'),
require('../themes/black.json5'), require('../themes/black.json5'),

View file

@ -12,12 +12,10 @@
accent: '#86b300', accent: '#86b300',
accentDarken: ':darken<10<@accent', accentDarken: ':darken<10<@accent',
accentLighten: ':lighten<10<@accent', accentLighten: ':lighten<10<@accent',
accentShadow: ':alpha<0.3<@accent',
focus: ':alpha<0.3<@accent', focus: ':alpha<0.3<@accent',
bg: '#000', bg: '#000',
fg: '#c7d1d8', fg: '#c7d1d8',
fgHighlighted: ':lighten<3<@fg', fgHighlighted: ':lighten<3<@fg',
html: '@bg',
divider: 'rgba(255, 255, 255, 0.1)', divider: 'rgba(255, 255, 255, 0.1)',
indicator: '@accent', indicator: '@accent',
panel: '#000', panel: '#000',

View file

@ -12,12 +12,10 @@
accent: '#86b300', accent: '#86b300',
accentDarken: ':darken<10<@accent', accentDarken: ':darken<10<@accent',
accentLighten: ':lighten<10<@accent', accentLighten: ':lighten<10<@accent',
accentShadow: ':alpha<0.4<@accent',
focus: ':alpha<0.3<@accent', focus: ':alpha<0.3<@accent',
bg: '#fafafa', bg: '#fafafa',
fg: '#5c6a73', fg: '#5c6a73',
fgHighlighted: ':darken<3<@fg', fgHighlighted: ':darken<3<@fg',
html: '@bg',
divider: 'rgba(0, 0, 0, 0.1)', divider: 'rgba(0, 0, 0, 0.1)',
indicator: '@accent', indicator: '@accent',
panel: '#fff', panel: '#fff',

View file

@ -12,7 +12,6 @@
panel: '#1f1d30', panel: '#1f1d30',
bg: '#0f0e17', bg: '#0f0e17',
fg: '#b1bee3', fg: '#b1bee3',
html: '@accent',
renote: '@accent', renote: '@accent',
}, },
} }