Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/features/settings/components/AppearanceSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Trans, useTranslation } from 'react-i18next'
import { Button } from '../../../components/ui/Button'
import { SunIcon, MoonIcon, SystemIcon, CheckIcon, ChevronDownIcon } from '../../../components/Icons'
import { Toggle, SegmentedControl, SettingRow, SettingsSection } from './SettingsUI'
import { CodeBlockThemeSettings } from './CodeBlockThemeSettings'
import { useTheme } from '../../../hooks'
import { getThemePreset } from '../../../themes'
import type { CustomCSSSnippet } from '../../../store/themeStore'
Expand Down Expand Up @@ -726,6 +727,8 @@ export function AppearanceSettings() {
</div>
</SettingRow>
</SettingsSection>

<CodeBlockThemeSettings />
</div>
)
}
193 changes: 193 additions & 0 deletions src/features/settings/components/CodeBlockThemeSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingRow, SettingsSection } from './SettingsUI'
import { useTheme } from '../../../hooks'
import {
AVAILABLE_CODE_BLOCK_THEMES,
filterThemesByType,
type CodeBlockThemeInfo,
} from '../../../lib/codeBlockThemes'
import { highlightHtmlInWorker } from '../../../lib/shikiWorkerClient'
import { ChevronDownIcon } from '../../../components/Icons'

// 共享的预览代码片段:覆盖关键字、字符串、注释、数字、函数调用、属性等常见 token
const PREVIEW_CODE = `// greet user by name
function greet(name: string): string {
const message = \`Hello, \${name}!\`
return message
}

const result = greet("world")
console.log(result)`

const PREVIEW_LANGUAGE = 'ts'

function themeDisplayName(id: string): string {
return AVAILABLE_CODE_BLOCK_THEMES.find(t => t.id === id)?.displayName ?? id
}

// ============================================
// Theme select dropdown
// ============================================

function CodeBlockThemeSelect({
value,
onChange,
type,
}: {
value: string
onChange: (id: string) => void
type: 'light' | 'dark'
}) {
// 同 type 的主题作为默认推荐组,其它 type 作为另一组放下面(用户仍可混搭)
const sameType = useMemo(() => filterThemesByType(type), [type])
const otherType = useMemo(() => filterThemesByType(type === 'light' ? 'dark' : 'light'), [type])

return (
<div className="relative inline-flex min-w-[180px]">
<select
value={value}
onChange={e => onChange(e.target.value)}
aria-label={`${type} code block theme`}
className="appearance-none pl-2 pr-8 py-1 text-[length:var(--fs-sm)] bg-bg-200/50 border border-border-200 rounded-md text-text-100 focus:outline-none focus:border-accent-main-100/50 cursor-pointer max-w-[220px]"
>
<optgroup label={type === 'light' ? 'Light themes' : 'Dark themes'}>
{sameType.map(t => (
<option key={t.id} value={t.id} className="bg-bg-100 text-text-100">
{t.displayName}
</option>
))}
</optgroup>
<optgroup label={type === 'light' ? 'Dark themes' : 'Light themes'}>
{otherType.map(t => (
<option key={t.id} value={t.id} className="bg-bg-100 text-text-100">
{t.displayName}
</option>
))}
</optgroup>
</select>
<ChevronDownIcon
size={14}
className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-text-300"
/>
</div>
)
}

// ============================================
// Live preview using Shiki
// ============================================

function CodeBlockPreview({ themeId, label }: { themeId: string; label: string }) {
const [html, setHtml] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const requestKeyRef = useRef(0)

useEffect(() => {
const key = `preview-${themeId}`
const myKey = ++requestKeyRef.current

let cancelled = false
highlightHtmlInWorker({
key,
text: PREVIEW_CODE,
language: PREVIEW_LANGUAGE,
theme: themeId as Parameters<typeof highlightHtmlInWorker>[0]['theme'],
})
.then(result => {
if (cancelled || myKey !== requestKeyRef.current) return
setHtml(result.html)
setError(null)
})
.catch(err => {
if (cancelled || myKey !== requestKeyRef.current) return
setError(err instanceof Error ? err.message : String(err))
setHtml(null)
})

return () => {
cancelled = true
}
}, [themeId])

return (
<div>
<div className="flex items-baseline justify-between mb-1.5">
<p className="text-[length:var(--fs-sm)] text-text-200">{label}</p>
<p className="text-[length:var(--fs-xs)] text-text-500">{themeDisplayName(themeId)}</p>
</div>
<div className="rounded-md overflow-hidden border border-border-200/40 text-[length:var(--fs-code)] leading-[var(--fs-code-line-height)]">
{html ? (
<div
className="shiki-preview-container overflow-x-auto"
// shiki 返回的 <pre><code> 自带 inline style (bg/fg/color),直接渲染
dangerouslySetInnerHTML={{ __html: html }}
/>
) : error ? (
<div className="px-3 py-2 bg-bg-200 text-text-400">{error}</div>
) : (
<pre className="px-3 py-2 bg-bg-200 text-text-400">
<code>{PREVIEW_CODE}</code>
</pre>
)}
</div>
</div>
)
}

// ============================================
// Main section
// ============================================

export function CodeBlockThemeSettings() {
const { t } = useTranslation(['settings', 'common'])
const {
codeBlockThemeLight,
codeBlockThemeDark,
setCodeBlockThemeLight,
setCodeBlockThemeDark,
resolvedTheme,
} = useTheme()

return (
<SettingsSection title={t('appearance.codeBlockThemes')}>
<p className="text-[length:var(--fs-sm)] text-text-400">{t('appearance.codeBlockThemesDesc')}</p>

<SettingRow
label={t('appearance.codeBlockThemeLight')}
description={t('appearance.codeBlockThemeLightDesc')}
>
<CodeBlockThemeSelect
value={codeBlockThemeLight}
onChange={setCodeBlockThemeLight}
type="light"
/>
</SettingRow>

<SettingRow
label={t('appearance.codeBlockThemeDark')}
description={t('appearance.codeBlockThemeDarkDesc')}
>
<CodeBlockThemeSelect
value={codeBlockThemeDark}
onChange={setCodeBlockThemeDark}
type="dark"
/>
</SettingRow>

<div className="space-y-4">
<CodeBlockPreview
themeId={resolvedTheme === 'dark' ? codeBlockThemeDark : codeBlockThemeLight}
label={
resolvedTheme === 'dark'
? t('appearance.codeBlockPreviewDark')
: t('appearance.codeBlockPreviewLight')
}
/>
</div>
</SettingsSection>
)
}

// 导出 unused type 仅用于未来扩展(被引用以避免 tree-shake 误删)
export type { CodeBlockThemeInfo }
10 changes: 10 additions & 0 deletions src/hooks/useSyntaxHighlight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ describe('getShikiTheme', () => {
expect(getShikiTheme(true).key).toBe('github-dark-default')
expect(getShikiTheme(false).key).toBe('github-light-default')
})

it('respects user-configured light/dark themes', () => {
expect(getShikiTheme(false, 'one-light', 'one-dark-pro').theme).toBe('one-light')
expect(getShikiTheme(true, 'one-light', 'one-dark-pro').theme).toBe('one-dark-pro')
})

it('falls back to GitHub Default when given an unknown theme id', () => {
expect(getShikiTheme(false, 'not-a-real-theme', 'also-fake').theme).toBe('github-light-default')
expect(getShikiTheme(true, 'not-a-real-theme', 'also-fake').theme).toBe('github-dark-default')
})
})

describe('Shiki language metadata', () => {
Expand Down
34 changes: 27 additions & 7 deletions src/hooks/useSyntaxHighlight.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,31 @@
import { useState, useEffect, useMemo, useRef, useId } from 'react'
import { useState, useEffect, useMemo, useRef, useId, useSyncExternalStore } from 'react'
import type { ShikiThemeInput } from '../lib/shikiTheme'
import { getShikiTheme, useIsDarkMode } from '../lib/shikiTheme'
import { disposeShikiWorkerKey, highlightHtmlInWorker, highlightTokensInWorker } from '../lib/shikiWorkerClient'
import type { HighlightTokens } from '../lib/highlightTypes'
import { normalizeLanguage } from '../utils/languageUtils'
import { THEME_SWITCH_DISABLE_MS } from '../constants'
import { themeStore } from '../store/themeStore'

export type { HighlightTokens } from '../lib/highlightTypes'
export type { ShikiThemeInput } from '../lib/shikiTheme'

// ============================================
// 代码块主题订阅(仅在 codeBlockThemeLight/Dark 变化时触发 re-render)
// ============================================

function codeBlockThemeKey(): string {
const s = themeStore.getState()
return s.codeBlockThemeLight + '|' + s.codeBlockThemeDark
}

function useCodeBlockThemes(): { light: string; dark: string } {
// 订阅派生字符串,避免其它 appearance 字段变化时让所有代码块重渲染
useSyncExternalStore(themeStore.subscribe, codeBlockThemeKey)
const s = themeStore.getState()
return { light: s.codeBlockThemeLight, dark: s.codeBlockThemeDark }
}

type IdleWindowApi = {
requestIdleCallback?: (callback: () => void, options?: { timeout?: number }) => number
cancelIdleCallback?: (id: number) => void
Expand Down Expand Up @@ -210,11 +227,12 @@ export function useStreamingSyntaxHighlight(
const { lang = 'text', theme, enabled = true } = options
const normalizedLang = normalizeLanguage(lang)
const isDark = useIsDarkMode()
const codeBlockThemes = useCodeBlockThemes()
const instanceId = useId()
const resolvedTheme = useMemo(() => {
if (theme) return { theme, key: theme }
return getShikiTheme(isDark)
}, [theme, isDark])
return getShikiTheme(isDark, codeBlockThemes.light, codeBlockThemes.dark)
}, [theme, isDark, codeBlockThemes.light, codeBlockThemes.dark])

const [outputState, setOutputState] = useState<{ code: string; tokens: HighlightTokens } | null>(null)
const [isLoading, setIsLoading] = useState(false)
Expand Down Expand Up @@ -295,13 +313,14 @@ export function useSyntaxHighlight(code: string, options: HighlightOptions & { m
const normalizedLang = normalizeLanguage(lang)

const isDark = useIsDarkMode()
const codeBlockThemes = useCodeBlockThemes()

const resolvedTheme = useMemo(() => {
if (theme) {
return { theme, key: theme }
}
return getShikiTheme(isDark)
}, [theme, isDark])
return getShikiTheme(isDark, codeBlockThemes.light, codeBlockThemes.dark)
}, [theme, isDark, codeBlockThemes.light, codeBlockThemes.dark])

const cacheKey = useMemo(
() => getCacheKey(code, normalizedLang, resolvedTheme.key),
Expand Down Expand Up @@ -406,12 +425,13 @@ export function useSyntaxHighlightRef(
const normalizedLang = normalizeLanguage(lang)

const isDark = useIsDarkMode()
const codeBlockThemes = useCodeBlockThemes()
const resolvedTheme = useMemo(() => {
if (theme) {
return { theme, key: theme }
}
return getShikiTheme(isDark)
}, [theme, isDark])
return getShikiTheme(isDark, codeBlockThemes.light, codeBlockThemes.dark)
}, [theme, isDark, codeBlockThemes.light, codeBlockThemes.dark])

const tokensRef = useRef<HighlightTokens | null>(null)
const [version, setVersion] = useState(0)
Expand Down
14 changes: 14 additions & 0 deletions src/hooks/useTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,14 @@ export function useTheme() {
themeStore.setProcessCollapseEnabled(enabled)
}, [])

const setCodeBlockThemeLight = useCallback((id: string) => {
themeStore.setCodeBlockThemeLight(id)
}, [])

const setCodeBlockThemeDark = useCallback((id: string) => {
themeStore.setCodeBlockThemeDark(id)
}, [])

return {
// 日夜模式(向后兼容)
mode: state.colorMode,
Expand Down Expand Up @@ -352,5 +360,11 @@ export function useTheme() {
// 过程折叠
processCollapseEnabled: state.processCollapseEnabled,
setProcessCollapseEnabled,

// 代码块主题(Shiki)
codeBlockThemeLight: state.codeBlockThemeLight,
codeBlockThemeDark: state.codeBlockThemeDark,
setCodeBlockThemeLight,
setCodeBlockThemeDark,
}
}
38 changes: 38 additions & 0 deletions src/lib/codeBlockThemes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Code block (Shiki) theme catalog + helpers.
*
* `bundledThemesInfo` 来自 `shiki/themes`,包含全部 65 个内置 Shiki 主题的
* `{ id, displayName, type }` 元数据。Worker 端使用同源的 lazy `import` 字段
* 按需加载,主线程只读元数据用于下拉菜单。
*/

import { bundledThemesInfo } from 'shiki/themes'
import type { BundledTheme } from 'shiki/themes'

export type ShikiThemeType = 'light' | 'dark'

export interface CodeBlockThemeInfo {
id: BundledTheme
displayName: string
type: ShikiThemeType
}

/** 全部 Shiki 内置主题元数据,按 displayName 字母序排序 */
export const AVAILABLE_CODE_BLOCK_THEMES: readonly CodeBlockThemeInfo[] = bundledThemesInfo
.map(t => ({ id: t.id as BundledTheme, displayName: t.displayName, type: t.type as ShikiThemeType }))
.sort((a, b) => a.displayName.localeCompare(b.displayName))

export const DEFAULT_CODE_BLOCK_THEME_LIGHT = 'github-light-default' as const
export const DEFAULT_CODE_BLOCK_THEME_DARK = 'github-dark-default' as const

const knownIds = new Set<string>(AVAILABLE_CODE_BLOCK_THEMES.map(t => t.id))

/** 校验 Shiki theme id 是否存在;不存在则回退到对应默认值 */
export function normalizeCodeBlockTheme(id: string, fallback: BundledTheme): BundledTheme {
return knownIds.has(id) ? (id as BundledTheme) : fallback
}

/** 按 type 过滤(light/dark) */
export function filterThemesByType(type: ShikiThemeType): readonly CodeBlockThemeInfo[] {
return AVAILABLE_CODE_BLOCK_THEMES.filter(t => t.type === type)
}
Loading
Loading