mirror of
				https://codeberg.org/yeentown/barkey.git
				synced 2025-10-31 13:34:12 +00:00 
			
		
		
		
	テーマエディターの実装 (#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: "プレイヤーを開く" | 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: "ノート" | ||||||
|  |  | ||||||
|  | @ -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, | ||||||
|  |  | ||||||
|  | @ -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> | ||||||
|  |  | ||||||
|  | @ -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) { | ||||||
|  |  | ||||||
							
								
								
									
										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: '/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') }, | ||||||
|  |  | ||||||
							
								
								
									
										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 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'), | ||||||
|  |  | ||||||
|  | @ -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', | ||||||
|  |  | ||||||
|  | @ -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', | ||||||
|  |  | ||||||
|  | @ -12,7 +12,6 @@ | ||||||
| 		panel: '#1f1d30', | 		panel: '#1f1d30', | ||||||
| 		bg: '#0f0e17', | 		bg: '#0f0e17', | ||||||
| 		fg: '#b1bee3', | 		fg: '#b1bee3', | ||||||
| 		html: '@accent', |  | ||||||
| 		renote: '@accent', | 		renote: '@accent', | ||||||
| 	}, | 	}, | ||||||
| } | } | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		
		Reference in a new issue