テーマエディターの実装 (#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:
		
							parent
							
								
									cf3fc97202
								
							
						
					
					
						commit
						80bebea9e6
					
				
					 11 changed files with 495 additions and 10 deletions
				
			
		|  | @ -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: "ノート" | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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) { | ||||
|  |  | |||
							
								
								
									
										343
									
								
								src/client/pages/theme-editor.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										343
									
								
								src/client/pages/theme-editor.vue
									
										
									
									
									
										Normal 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> | ||||
|  | @ -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') }, | ||||
|  |  | |||
							
								
								
									
										74
									
								
								src/client/scripts/theme-editor.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								src/client/scripts/theme-editor.ts
									
										
									
									
									
										Normal 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; | ||||
| }; | ||||
|  | @ -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'), | ||||
|  |  | |||
|  | @ -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', | ||||
|  |  | |||
|  | @ -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', | ||||
|  |  | |||
|  | @ -12,7 +12,6 @@ | |||
| 		panel: '#1f1d30', | ||||
| 		bg: '#0f0e17', | ||||
| 		fg: '#b1bee3', | ||||
| 		html: '@accent', | ||||
| 		renote: '@accent', | ||||
| 	}, | ||||
| } | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue