Skip to content

Commit

Permalink
enhance(frontend): シンタックスハイライトにテーマを適用できるように (#13175)
Browse files Browse the repository at this point in the history
* enhance(frontend): シンタックスハイライトにテーマを適用できるように

* Update Changelog

* こっちも

* テーマの値がディープマージされるように

* 常にテーマ設定に準じるように

* テーマ更新時に新しいshikiテーマを読み込むように
  • Loading branch information
kakkokari-gtyih committed Feb 6, 2024
1 parent 2f54a53 commit 16eccad
Show file tree
Hide file tree
Showing 16 changed files with 202 additions and 42 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
- Enhance: MFMの属性でオートコンプリートが使用できるように #12735
- Enhance: 絵文字編集ダイアログをモーダルではなくウィンドウで表示するように
- Enhance: リモートのユーザーはメニューから直接リモートで表示できるように
- Enhance: コードのシンタックスハイライトにテーマを適用できるように
- Fix: ネイティブモードの絵文字がモノクロにならないように
- Fix: v2023.12.0で追加された「モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能」が管理画面上で正しく表示されていない問題を修正
- Fix: AiScriptの`readline`関数が不正な値を返すことがある問題のv2023.12.0時点での修正がPlay以外に適用されていないのを修正
Expand Down
56 changes: 52 additions & 4 deletions packages/frontend/src/components/MkCode.core.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only

<!-- eslint-disable vue/no-v-html -->
<template>
<div :class="[$style.codeBlockRoot, { [$style.codeEditor]: codeEditor }]" v-html="html"></div>
<div :class="[$style.codeBlockRoot, { [$style.codeEditor]: codeEditor }, (darkMode ? $style.dark : $style.light)]" v-html="html"></div>
</template>

<script lang="ts" setup>
import { ref, computed, watch } from 'vue';
import { bundledLanguagesInfo } from 'shiki';
import type { BuiltinLanguage } from 'shiki';
import { getHighlighter } from '@/scripts/code-highlighter.js';
import { getHighlighter, getTheme } from '@/scripts/code-highlighter.js';
import { defaultStore } from '@/store.js';
const props = defineProps<{
code: string;
Expand All @@ -21,11 +22,23 @@ const props = defineProps<{
}>();
const highlighter = await getHighlighter();
const darkMode = defaultStore.reactiveState.darkMode;
const codeLang = ref<BuiltinLanguage | 'aiscript'>('js');
const [lightThemeName, darkThemeName] = await Promise.all([
getTheme('light', true),
getTheme('dark', true),
]);
const html = computed(() => highlighter.codeToHtml(props.code, {
lang: codeLang.value,
theme: 'dark-plus',
themes: {
fallback: 'dark-plus',
light: lightThemeName,
dark: darkThemeName,
},
defaultColor: false,
cssVariablePrefix: '--shiki-',
}));
async function fetchLanguage(to: string): Promise<void> {
Expand Down Expand Up @@ -64,13 +77,42 @@ watch(() => props.lang, (to) => {
margin: .5em 0;
overflow: auto;
border-radius: 8px;
border: 1px solid var(--divider);
color: var(--shiki-fallback);
background-color: var(--shiki-fallback-bg);
& span {
color: var(--shiki-fallback);
background-color: var(--shiki-fallback-bg);
}
& pre,
& code {
font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
}
}
.light.codeBlockRoot :global(.shiki) {
color: var(--shiki-light);
background-color: var(--shiki-light-bg);
& span {
color: var(--shiki-light);
background-color: var(--shiki-light-bg);
}
}
.dark.codeBlockRoot :global(.shiki) {
color: var(--shiki-dark);
background-color: var(--shiki-dark-bg);
& span {
color: var(--shiki-dark);
background-color: var(--shiki-dark-bg);
}
}
.codeBlockRoot.codeEditor {
min-width: 100%;
height: 100%;
Expand All @@ -79,6 +121,7 @@ watch(() => props.lang, (to) => {
padding: 12px;
margin: 0;
border-radius: 6px;
border: none;
min-height: 130px;
pointer-events: none;
min-width: calc(100% - 24px);
Expand All @@ -90,6 +133,11 @@ watch(() => props.lang, (to) => {
text-rendering: inherit;
text-transform: inherit;
white-space: pre;
& span {
display: inline-block;
min-height: 1em;
}
}
}
</style>
8 changes: 3 additions & 5 deletions packages/frontend/src/components/MkCode.vue
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ function copy() {
}
.codeBlockCopyButton {
color: #D4D4D4;
position: absolute;
top: 8px;
right: 8px;
Expand All @@ -67,8 +66,7 @@ function copy() {
.codeBlockFallbackRoot {
display: block;
overflow-wrap: anywhere;
color: #D4D4D4;
background: #1E1E1E;
background: var(--bg);
padding: 1em;
margin: .5em 0;
overflow: auto;
Expand All @@ -93,8 +91,8 @@ function copy() {
border-radius: 8px;
padding: 24px;
margin-top: 4px;
color: #D4D4D4;
background: #1E1E1E;
color: var(--fg);
background: var(--bg);
}
.codePlaceholderContainer {
Expand Down
5 changes: 3 additions & 2 deletions packages/frontend/src/components/MkCodeEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -196,10 +196,11 @@ watch(v, newValue => {
resize: none;
text-align: left;
color: transparent;
caret-color: rgb(225, 228, 232);
caret-color: var(--fg);
background-color: transparent;
border: 0;
border-radius: 6px;
box-sizing: border-box;
outline: 0;
min-width: calc(100% - 24px);
height: 100%;
Expand All @@ -210,6 +211,6 @@ watch(v, newValue => {
}
.textarea::selection {
color: #fff;
color: var(--bg);
}
</style>
3 changes: 1 addition & 2 deletions packages/frontend/src/components/MkCodeInline.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ const props = defineProps<{
display: inline-block;
font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
overflow-wrap: anywhere;
color: #D4D4D4;
background: #1E1E1E;
background: var(--bg);
padding: .1em;
border-radius: .3em;
}
Expand Down
7 changes: 6 additions & 1 deletion packages/frontend/src/components/MkSelect.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div :class="$style.caption"><slot name="caption"></slot></div>

<MkButton v-if="manualSave && changed" primary @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
<MkButton v-if="manualSave && changed" primary :class="$style.save" @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
</div>
</template>

Expand Down Expand Up @@ -138,6 +138,7 @@ function show() {
active: computed(() => v.value === option.props?.value),
action: () => {
v.value = option.props?.value;
changed.value = true;
emit('changeByUser', v.value);
},
});
Expand Down Expand Up @@ -288,6 +289,10 @@ function show() {
padding-left: 6px;
}
.save {
margin: 8px 0 0 0;
}
.chevron {
transition: transform 0.1s ease-out;
}
Expand Down
15 changes: 14 additions & 1 deletion packages/frontend/src/pages/settings/theme.vue
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,18 @@ import { uniqueBy } from '@/scripts/array.js';
import { fetchThemes, getThemes } from '@/theme-store.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { miLocalStorage } from '@/local-storage.js';
import { unisonReload } from '@/scripts/unison-reload.js';
import * as os from '@/os.js';
async function reloadAsk() {
const { canceled } = await os.confirm({
type: 'info',
text: i18n.ts.reloadToApplySetting,
});
if (canceled) return;
unisonReload();
}
const installedThemes = ref(getThemes());
const builtinThemes = getBuiltinThemesRef();
Expand Down Expand Up @@ -124,6 +136,7 @@ const lightThemeId = computed({
}
},
});
const darkMode = computed(defaultStore.makeGetterSetter('darkMode'));
const syncDeviceDarkMode = computed(ColdDeviceStorage.makeGetterSetter('syncDeviceDarkMode'));
const wallpaper = ref(miLocalStorage.getItem('wallpaper'));
Expand All @@ -141,7 +154,7 @@ watch(wallpaper, () => {
} else {
miLocalStorage.setItem('wallpaper', wallpaper.value);
}
location.reload();
reloadAsk();
});
onActivated(() => {
Expand Down
23 changes: 2 additions & 21 deletions packages/frontend/src/pizzax.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { get, set } from '@/scripts/idb-proxy.js';
import { defaultStore } from '@/store.js';
import { useStream } from '@/stream.js';
import { deepClone } from '@/scripts/clone.js';
import { deepMerge } from '@/scripts/merge.js';

type StateDef = Record<string, {
where: 'account' | 'device' | 'deviceAccount';
Expand Down Expand Up @@ -84,29 +85,9 @@ export class Storage<T extends StateDef> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

/**
* valueにないキーをdefからもらう(再帰的)\
* nullはそのまま、undefinedはdefの値
**/
private mergeObject<X>(value: X, def: X): X {
if (this.isPureObject(value) && this.isPureObject(def)) {
const result = structuredClone(value) as X;
for (const [k, v] of Object.entries(def) as [keyof X, X[keyof X]][]) {
if (!Object.prototype.hasOwnProperty.call(value, k) || value[k] === undefined) {
result[k] = v;
} else if (this.isPureObject(v) && this.isPureObject(result[k])) {
const child = structuredClone(result[k]) as X[keyof X] & Record<string | number | symbol, unknown>;
result[k] = this.mergeObject<typeof v>(child, v);
}
}
return result;
}
return value;
}

private mergeState<X>(value: X, def: X): X {
if (this.isPureObject(value) && this.isPureObject(def)) {
const merged = this.mergeObject(value, def);
const merged = deepMerge(value, def);

if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def, ' Result: ', merged);

Expand Down
4 changes: 4 additions & 0 deletions packages/frontend/src/router/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ class MainRouterProxy implements IRouter {
return this.supplier().resolve(path);
}

init(): void {
this.supplier().init();
}

eventNames(): Array<EventEmitter.EventNames<RouterEvent>> {
return this.supplier().eventNames();
}
Expand Down
4 changes: 2 additions & 2 deletions packages/frontend/src/scripts/clone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
// あと、Vue RefをIndexedDBに保存しようとしてstructredCloneを使ったらエラーになった
// https://github.com/misskey-dev/misskey/pull/8098#issuecomment-1114144045

type Cloneable = string | number | boolean | null | undefined | { [key: string]: Cloneable } | Cloneable[];
export type Cloneable = string | number | boolean | null | undefined | { [key: string]: Cloneable } | { [key: number]: Cloneable } | { [key: symbol]: Cloneable } | Cloneable[];

export function deepClone<T extends Cloneable>(x: T): T {
if (typeof x === 'object') {
if (x === null) return x;
if (Array.isArray(x)) return x.map(deepClone) as T;
const obj = {} as Record<string, Cloneable>;
const obj = {} as Record<string | number | symbol, Cloneable>;
for (const [k, v] of Object.entries(x)) {
obj[k] = v === undefined ? undefined : deepClone(v);
}
Expand Down
68 changes: 65 additions & 3 deletions packages/frontend/src/scripts/code-highlighter.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,51 @@
import { bundledThemesInfo } from 'shiki';
import { getHighlighterCore, loadWasm } from 'shiki/core';
import darkPlus from 'shiki/themes/dark-plus.mjs';
import type { Highlighter, LanguageRegistration } from 'shiki';
import { unique } from './array.js';
import { deepClone } from './clone.js';
import { deepMerge } from './merge.js';
import type { Highlighter, LanguageRegistration, ThemeRegistration, ThemeRegistrationRaw } from 'shiki';
import { ColdDeviceStorage } from '@/store.js';
import lightTheme from '@/themes/_light.json5';
import darkTheme from '@/themes/_dark.json5';

let _highlighter: Highlighter | null = null;

export async function getTheme(mode: 'light' | 'dark', getName: true): Promise<string>;
export async function getTheme(mode: 'light' | 'dark', getName?: false): Promise<ThemeRegistration | ThemeRegistrationRaw>;
export async function getTheme(mode: 'light' | 'dark', getName = false): Promise<ThemeRegistration | ThemeRegistrationRaw | string | null> {
const theme = deepClone(ColdDeviceStorage.get(mode === 'light' ? 'lightTheme' : 'darkTheme'));

if (theme.base) {
const base = [lightTheme, darkTheme].find(x => x.id === theme.base);
if (base && base.codeHighlighter) theme.codeHighlighter = Object.assign({}, base.codeHighlighter, theme.codeHighlighter);
}

if (theme.codeHighlighter) {
let _res: ThemeRegistration = {};
if (theme.codeHighlighter.base === '_none_') {
_res = deepClone(theme.codeHighlighter.overrides);
} else {
const base = await bundledThemesInfo.find(t => t.id === theme.codeHighlighter!.base)?.import() ?? darkPlus;
_res = deepMerge(theme.codeHighlighter.overrides ?? {}, 'default' in base ? base.default : base);
}
if (_res.name == null) {
_res.name = theme.id;
}
_res.type = mode;

if (getName) {
return _res.name;
}
return _res;
}

if (getName) {
return 'dark-plus';
}
return darkPlus;
}

export async function getHighlighter(): Promise<Highlighter> {
if (!_highlighter) {
return await initHighlighter();
Expand All @@ -13,11 +55,17 @@ export async function getHighlighter(): Promise<Highlighter> {

export async function initHighlighter() {
const aiScriptGrammar = await import('aiscript-vscode/aiscript/syntaxes/aiscript.tmLanguage.json');

await loadWasm(import('shiki/onig.wasm?init'));

// テーマの重複を消す
const themes = unique([
darkPlus,
...(await Promise.all([getTheme('light'), getTheme('dark')])),
]);

const highlighter = await getHighlighterCore({
themes: [darkPlus],
themes,
langs: [
import('shiki/langs/javascript.mjs'),
{
Expand All @@ -27,6 +75,20 @@ export async function initHighlighter() {
],
});

ColdDeviceStorage.watch('lightTheme', async () => {
const newTheme = await getTheme('light');
if (newTheme.name && !highlighter.getLoadedThemes().includes(newTheme.name)) {
highlighter.loadTheme(newTheme);
}
});

ColdDeviceStorage.watch('darkTheme', async () => {
const newTheme = await getTheme('dark');
if (newTheme.name && !highlighter.getLoadedThemes().includes(newTheme.name)) {
highlighter.loadTheme(newTheme);
}
});

_highlighter = highlighter;

return highlighter;
Expand Down
Loading

0 comments on commit 16eccad

Please sign in to comment.