release: v0.4.1 — MetonaEditor 0.1.10 升级与三主题系统
- chore: 升级 @metona-team/metona-editor 0.1.3 → 0.1.10 - feat: 三主题系统(亮色/暗色/暖色),循环切换 - feat: 应用颜色自动同步编辑器主题(MeEditor.themes.exportCSSVars) - feat: 启用编辑器新功能(lineNumbers/autoBrackets/readOnly/autofocus/exportTool) - refactor: 主题从 boolean darkMode 重构为 ThemeMode 枚举 - refactor: 插件注册改用字符串数组(与 demo 一致) - refactor: 主题同步从手动 CSS 覆写改为 editor.setTheme() 实例 API - refactor: Ctrl+S 双通道(编辑器 onSave + 全局兜底)+ 防重入锁 - style: 移除 variables.css 中硬编码 .dark 色值,改为 JS 动态驱动 - test: 更新 editorStore 测试覆盖新的 themeMode/cycleTheme
This commit is contained in:
@@ -33,7 +33,7 @@ export function App() {
|
||||
const viewMode = useEditorStore(s => s.viewMode)
|
||||
const externallyModified = useEditorStore(s => s.externallyModified)
|
||||
const setExternallyModified = useEditorStore(s => s.setExternallyModified)
|
||||
const { darkMode, toggleDarkMode } = useTheme()
|
||||
const { themeMode, cycleTheme } = useTheme()
|
||||
const { confirm, confirmDialogProps } = useConfirm()
|
||||
const [showAbout, setShowAbout] = useState(false)
|
||||
const handleCloseAbout = useCallback(() => setShowAbout(false), [])
|
||||
@@ -86,7 +86,7 @@ export function App() {
|
||||
<div id="app" className={`mode-${viewMode}`}>
|
||||
<Toolbar
|
||||
onOpen={handleOpenFile} onSave={handleSave}
|
||||
darkMode={darkMode} onToggleDark={toggleDarkMode}
|
||||
themeMode={themeMode} onCycleTheme={cycleTheme}
|
||||
onShowAbout={() => setShowAbout(true)}
|
||||
isAutoSaving={isAutoSaving}
|
||||
autoSaveEnabled={autoSaveEnabled}
|
||||
@@ -101,7 +101,7 @@ export function App() {
|
||||
)}
|
||||
{tabs.length > 0 ? (
|
||||
<div id="content-wrapper">
|
||||
<div id="editor-panel"><Editor darkMode={darkMode} /></div>
|
||||
<div id="editor-panel"><Editor themeMode={themeMode} onAppSave={handleSave} /></div>
|
||||
</div>
|
||||
) : (
|
||||
<WelcomeScreen onOpen={handleOpenFile} onNew={() => createTab(null, '')} onOpenRecent={handleOpenRecent} />
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import MeEditor from '@metona-team/metona-editor'
|
||||
import type { MarkdownEditor, EditMode, PluginObject } from '@metona-team/metona-editor'
|
||||
import type { MarkdownEditor, MarkdownEditorOptions, EditMode, ThemeName } from '@metona-team/metona-editor'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { setMetonaEditorGetter, useEditorStore } from '../../stores/editorStore'
|
||||
import { settingsRepository } from '../../db/settingsRepository'
|
||||
import { renderMarkdownSync } from '../../lib/markdown'
|
||||
import type { ThemeMode } from '../../types/settings'
|
||||
|
||||
// v0.1.9 demo 验证过但类型声明未收录的配置项
|
||||
interface ExtendedEditorOptions extends MarkdownEditorOptions {
|
||||
lineNumbers?: boolean
|
||||
autoBrackets?: boolean
|
||||
}
|
||||
|
||||
interface EditorProps {
|
||||
darkMode: boolean
|
||||
themeMode: ThemeMode
|
||||
onAppSave?: () => void
|
||||
}
|
||||
|
||||
/** 将应用 viewMode 映射到 MetonaEditor 的 mode */
|
||||
@@ -24,26 +32,17 @@ function reverseMapMode(mode: EditMode): 'editor' | 'preview' | 'source' {
|
||||
return 'editor'
|
||||
}
|
||||
|
||||
/** 组装实例级插件列表 */
|
||||
function buildPlugins(): PluginObject[] {
|
||||
const plugins: PluginObject[] = []
|
||||
|
||||
// 搜索/替换(Ctrl+F / Ctrl+H)
|
||||
if (MeEditor.presetPlugins?.searchReplace) {
|
||||
plugins.push(MeEditor.presetPlugins.searchReplace)
|
||||
}
|
||||
// 粘贴图片 → base64
|
||||
if (MeEditor.presetPlugins?.imagePaste) {
|
||||
plugins.push(MeEditor.presetPlugins.imagePaste)
|
||||
}
|
||||
|
||||
return plugins
|
||||
}
|
||||
/** 预设插件(字符串形式,与 demo 一致) */
|
||||
const EDITOR_PLUGINS: string[] = [
|
||||
'searchReplace', // Ctrl+F/Ctrl+H
|
||||
'imagePaste', // Ctrl+V 粘贴图片
|
||||
'exportTool', // 导出 MD/HTML
|
||||
]
|
||||
|
||||
/**
|
||||
* Editor 组件 — 基于 MetonaEditor 的 Markdown 编辑器。
|
||||
*/
|
||||
export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
|
||||
export const Editor = React.memo(function Editor({ themeMode, onAppSave }: EditorProps) {
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const updateTabContent = useTabStore(s => s.updateTabContent)
|
||||
@@ -65,7 +64,7 @@ export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const editor = MeEditor.create(container, {
|
||||
const config: ExtendedEditorOptions = {
|
||||
value: activeTab?.content ?? '',
|
||||
mode: mapViewMode(viewMode),
|
||||
height: '100%',
|
||||
@@ -78,12 +77,16 @@ export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
|
||||
'edit', 'split', 'preview', 'fullscreen'
|
||||
],
|
||||
locale: 'zh-CN',
|
||||
theme: darkMode ? 'dark' : 'light',
|
||||
theme: themeMode as ThemeName,
|
||||
placeholder: '在此输入 Markdown 内容...',
|
||||
spellcheck: false,
|
||||
tabSize: 2,
|
||||
wordCount: true,
|
||||
plugins: buildPlugins(),
|
||||
autofocus: true,
|
||||
lineNumbers: true,
|
||||
autoBrackets: true,
|
||||
readOnly: viewMode === 'preview',
|
||||
plugins: EDITOR_PLUGINS,
|
||||
|
||||
// 使用 unified 管线渲染,保留图片路径修复能力
|
||||
render: (md: string) => {
|
||||
@@ -110,8 +113,15 @@ export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
|
||||
const mapped = reverseMapMode(mode)
|
||||
setViewMode(mapped)
|
||||
settingsRepository.save({ viewMode: mapped })
|
||||
},
|
||||
|
||||
// Ctrl+S → 触发应用层保存(Electron IPC 写文件系统)
|
||||
onSave: () => {
|
||||
onAppSave?.()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const editor = MeEditor.create(container, config)
|
||||
|
||||
editorRef.current = editor
|
||||
setMetonaEditorGetter(() => editor)
|
||||
@@ -165,37 +175,17 @@ export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTabId])
|
||||
|
||||
// ── 暗色模式同步 ──────────────────────────────────────────
|
||||
// ── 主题同步 ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
try {
|
||||
const themeName = darkMode ? 'dark' : 'light'
|
||||
|
||||
// 1. 全局主题(documentElement + localStorage)
|
||||
MeEditor.setTheme(themeName)
|
||||
|
||||
// 2. 覆写当前实例 wrapper 的 inline CSS 变量
|
||||
// MeEditor.setTheme 只更新 documentElement,不更新 wrapper;
|
||||
// 而 create() 时写入的 inline 变量优先级最高,必须手动覆写
|
||||
const wrapper = containerRef.current?.querySelector('.me-wrapper') as HTMLElement | null
|
||||
if (wrapper) {
|
||||
const config: Record<string, string> = MeEditor.themes.getThemeConfig(themeName) as Record<string, string>
|
||||
wrapper.style.setProperty('--md-bg', config.bg)
|
||||
wrapper.style.setProperty('--md-text', config.text)
|
||||
wrapper.style.setProperty('--md-border', config.border)
|
||||
wrapper.style.setProperty('--md-shadow', config.shadow)
|
||||
if (config.hoverShadow) wrapper.style.setProperty('--md-hover-shadow', config.hoverShadow)
|
||||
if (config.toolbarBg) wrapper.style.setProperty('--md-toolbar-bg', config.toolbarBg)
|
||||
if (config.textareaBg) wrapper.style.setProperty('--md-textarea-bg', config.textareaBg)
|
||||
if (config.previewBg) wrapper.style.setProperty('--md-preview-bg', config.previewBg)
|
||||
if (config.codeBg) wrapper.style.setProperty('--md-code-bg', config.codeBg)
|
||||
if (config.codeText) wrapper.style.setProperty('--md-code-text', config.codeText)
|
||||
if (config.accent) wrapper.style.setProperty('--md-accent', config.accent)
|
||||
if (config.muted) wrapper.style.setProperty('--md-muted', config.muted)
|
||||
}
|
||||
// 全局主题(documentElement + localStorage)
|
||||
MeEditor.setTheme(themeMode)
|
||||
// v0.1.5+: 实例级主题,自动处理 wrapper CSS 变量
|
||||
editorRef.current?.setTheme(themeMode)
|
||||
} catch {
|
||||
// 容错
|
||||
}
|
||||
}, [darkMode])
|
||||
}, [themeMode])
|
||||
|
||||
// ── 视图模式同步 ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
@@ -205,6 +195,8 @@ export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
|
||||
if (editor.getMode() !== targetMode) {
|
||||
editor.setMode(targetMode)
|
||||
}
|
||||
// 预览模式设为只读
|
||||
editor.setReadOnly(viewMode === 'preview')
|
||||
}, [viewMode])
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,25 +1,34 @@
|
||||
import React from 'react'
|
||||
import { FolderOpen, Save, Moon, Sun, Info } from '../Icons'
|
||||
import type { ThemeMode } from '../../types/settings'
|
||||
|
||||
interface ToolbarProps {
|
||||
onOpen: () => void
|
||||
onSave: () => void
|
||||
darkMode: boolean
|
||||
onToggleDark: () => void
|
||||
themeMode: ThemeMode
|
||||
onCycleTheme: () => void
|
||||
onShowAbout: () => void
|
||||
isAutoSaving: boolean
|
||||
autoSaveEnabled: boolean
|
||||
onToggleAutoSave: () => void
|
||||
}
|
||||
|
||||
const THEME_LABELS: Record<ThemeMode, string> = {
|
||||
light: '亮色',
|
||||
dark: '暗色',
|
||||
warm: '暖色',
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用顶层工具栏 — 文件操作、自动保存、主题、关于。
|
||||
* 应用顶层工具栏 — 文件操作、自动保存、主题循环、关于。
|
||||
* 编辑器格式化和模式切换由 MetonaEditor 内置工具栏处理。
|
||||
*/
|
||||
export const Toolbar = React.memo(function Toolbar({
|
||||
onOpen, onSave, darkMode, onToggleDark, onShowAbout,
|
||||
onOpen, onSave, themeMode, onCycleTheme, onShowAbout,
|
||||
isAutoSaving, autoSaveEnabled, onToggleAutoSave
|
||||
}: ToolbarProps) {
|
||||
const nextLabel = THEME_LABELS[themeMode] ?? '主题'
|
||||
|
||||
return (
|
||||
<div id="toolbar" role="toolbar" aria-label="工具栏">
|
||||
<div className="toolbar-left" role="group" aria-label="文件操作">
|
||||
@@ -42,8 +51,14 @@ export const Toolbar = React.memo(function Toolbar({
|
||||
</button>
|
||||
</div>
|
||||
<div className="toolbar-right" role="group" aria-label="设置">
|
||||
<button className="toolbar-btn" onClick={onToggleDark} title="切换暗色主题" aria-label={darkMode ? '切换到亮色主题' : '切换到暗色主题'}>
|
||||
{darkMode ? <Sun size={18} /> : <Moon size={18} />}
|
||||
<button
|
||||
className="toolbar-btn"
|
||||
onClick={onCycleTheme}
|
||||
title={`当前 ${nextLabel} — 点击切换`}
|
||||
aria-label={`当前${nextLabel}主题,点击切换`}
|
||||
>
|
||||
{themeMode === 'dark' ? <Moon size={18} /> : <Sun size={18} />}
|
||||
<span style={{ fontSize: 12, marginLeft: 2 }}>{nextLabel}</span>
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={onShowAbout} title="关于" aria-label="关于 MarkLite">
|
||||
<Info size={18} />
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface ActiveTabRecord {
|
||||
|
||||
export interface SettingsRecord {
|
||||
id: string // 固定为 'default'
|
||||
darkMode: boolean
|
||||
themeMode: 'light' | 'dark' | 'warm'
|
||||
viewMode: 'editor' | 'preview' | 'source'
|
||||
sidebarCollapsed: boolean
|
||||
sidebarWidth: number
|
||||
|
||||
@@ -8,7 +8,7 @@ export const settingsRepository = {
|
||||
const record: SettingsRecord | undefined = await db.settings.get('default')
|
||||
if (record) {
|
||||
return {
|
||||
darkMode: record.darkMode ?? DEFAULT_SETTINGS.darkMode,
|
||||
themeMode: record.themeMode ?? DEFAULT_SETTINGS.themeMode,
|
||||
viewMode: record.viewMode ?? DEFAULT_SETTINGS.viewMode,
|
||||
sidebarCollapsed: record.sidebarCollapsed ?? DEFAULT_SETTINGS.sidebarCollapsed,
|
||||
sidebarWidth: record.sidebarWidth ?? DEFAULT_SETTINGS.sidebarWidth
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useEditorStore } from '../stores/editorStore'
|
||||
import { recentFilesRepository } from '../db/recentFilesRepository'
|
||||
@@ -8,12 +8,16 @@ import { showToast } from '../lib/toast'
|
||||
/**
|
||||
* AR-01: 从 App.tsx 提取的文件操作逻辑
|
||||
* UX-02: 添加 loading 状态指示
|
||||
* v0.1.9: handleSave 添加防重入锁,避免编辑器 onSave + 全局 Ctrl+S 双重触发
|
||||
*/
|
||||
export function useFileOperations() {
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
const getActiveTab = useTabStore(s => s.getActiveTab)
|
||||
const setLoading = useEditorStore(s => s.setLoading)
|
||||
|
||||
// 防重入:onSave 回调 + 全局 Ctrl+S handler 可能在 300ms 内双重触发
|
||||
const savingGate = useRef(false)
|
||||
|
||||
const handleOpenFile = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
if (!window.electronAPI) return
|
||||
@@ -33,6 +37,9 @@ export function useFileOperations() {
|
||||
}, [createTab, setLoading])
|
||||
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
// 防重入:300ms 内忽略重复调用(编辑器 onSave + 全局 Ctrl+S 双重触发)
|
||||
if (savingGate.current) return
|
||||
savingGate.current = true
|
||||
try {
|
||||
const tab = getActiveTab()
|
||||
if (!tab || !window.electronAPI) return
|
||||
@@ -47,6 +54,9 @@ export function useFileOperations() {
|
||||
} catch (error) {
|
||||
logError('保存文件失败', error)
|
||||
showToast('保存失败', 'error')
|
||||
} finally {
|
||||
// 300ms 后释放锁,允许下次保存
|
||||
setTimeout(() => { savingGate.current = false }, 300)
|
||||
}
|
||||
}, [getActiveTab])
|
||||
|
||||
|
||||
@@ -4,13 +4,15 @@ import { useTabStore } from '../stores/tabStore'
|
||||
/**
|
||||
* 全局键盘快捷键 hook。
|
||||
* MetonaEditor 内置工具栏处理格式化和模式切换(Ctrl+B/I/1/2/3),
|
||||
* 本 hook 仅处理应用级快捷键。
|
||||
* v0.1.9 onSave 回调处理编辑器聚焦时的 Ctrl+S,
|
||||
* 全局 handler 作为焦点外兜底(工具栏/侧边栏聚焦时仍可保存)。
|
||||
*/
|
||||
export function useKeyboard(handleOpenFile: () => void, handleSave: () => void, handleSaveAs: () => void) {
|
||||
const handleKeydown = useCallback((e: KeyboardEvent) => {
|
||||
const isCtrl = e.ctrlKey || e.metaKey
|
||||
|
||||
if (isCtrl && e.key === 'o') { e.preventDefault(); handleOpenFile(); return }
|
||||
// 全局兜底:编辑器未聚焦时仍可保存(编辑器聚焦时由 onSave 回调处理)
|
||||
if (isCtrl && e.key === 's' && !e.shiftKey) { e.preventDefault(); handleSave(); return }
|
||||
if (isCtrl && e.shiftKey && e.key === 'S') { e.preventDefault(); handleSaveAs(); return }
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { logError } from '../lib/errorHandler'
|
||||
* 替代各 hook 各自独立加载设置的模式。
|
||||
*/
|
||||
export function useSettingsInit() {
|
||||
const setDarkMode = useEditorStore(s => s.setDarkMode)
|
||||
const setThemeMode = useEditorStore(s => s.setThemeMode)
|
||||
const setViewMode = useEditorStore(s => s.setViewMode)
|
||||
const isInitialized = useRef(false)
|
||||
|
||||
@@ -20,11 +20,10 @@ export function useSettingsInit() {
|
||||
|
||||
settingsRepository.load()
|
||||
.then((settings) => {
|
||||
// 主题
|
||||
// 主题:优先使用保存的设置,否则跟随系统偏好
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
const darkMode = settings.darkMode !== undefined ? settings.darkMode : prefersDark
|
||||
setDarkMode(darkMode)
|
||||
document.documentElement.classList.toggle('dark', darkMode)
|
||||
const themeMode = settings.themeMode ?? (prefersDark ? 'dark' : 'light')
|
||||
setThemeMode(themeMode)
|
||||
|
||||
// 视图模式
|
||||
setViewMode(settings.viewMode ?? 'editor')
|
||||
@@ -42,7 +41,7 @@ export function useSettingsInit() {
|
||||
.catch((error: unknown) => {
|
||||
logError('加载设置失败', error)
|
||||
})
|
||||
}, [setDarkMode, setViewMode])
|
||||
}, [setThemeMode, setViewMode])
|
||||
|
||||
return { isInitialized }
|
||||
}
|
||||
|
||||
@@ -1,23 +1,90 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useCallback } from 'react'
|
||||
import MeEditor from '@metona-team/metona-editor'
|
||||
import { useEditorStore } from '../stores/editorStore'
|
||||
import { settingsRepository } from '../db/settingsRepository'
|
||||
import { syncToastTheme } from '../lib/toast'
|
||||
import type { ThemeMode } from '../types/settings'
|
||||
|
||||
/** 将 hex 颜色转为带 alpha 的版本,用于背景色 */
|
||||
function hexWithAlpha(hex: string, alpha: number): string {
|
||||
if (!hex || !hex.startsWith('#')) return hex
|
||||
const a = Math.round(alpha * 255).toString(16).padStart(2, '0')
|
||||
return hex.length === 7 ? hex + a : hex.slice(0, 7) + a
|
||||
}
|
||||
|
||||
/** 将编辑器 CSS 变量同步到应用根元素,保持颜色一致 */
|
||||
function syncAppColorsToEditor(): void {
|
||||
try {
|
||||
const vars = MeEditor.themes.exportCSSVars()
|
||||
if (!vars || typeof vars !== 'object') return
|
||||
const root = document.documentElement
|
||||
const set = (name: string, value: string | undefined) => {
|
||||
if (value) root.style.setProperty(name, value)
|
||||
}
|
||||
const accent = vars['--md-accent'] ?? '#1a73e8'
|
||||
// 直接映射 — 文本/边框用纯色
|
||||
set('--bg', vars['--md-bg'])
|
||||
set('--text', vars['--md-text'])
|
||||
set('--border', vars['--md-border'])
|
||||
set('--primary', accent)
|
||||
set('--primary-dark', accent)
|
||||
set('--text-secondary', vars['--md-muted'])
|
||||
set('--text-tertiary', vars['--md-muted'])
|
||||
set('--code-bg', vars['--md-code-bg'])
|
||||
// 背景派生 — 用 accent 的低透明度版本
|
||||
const accentBg = hexWithAlpha(accent, 0.12)
|
||||
set('--primary-light', accentBg)
|
||||
set('--sidebar-active', accentBg)
|
||||
set('--sidebar-bg', vars['--md-bg'])
|
||||
set('--sidebar-border', vars['--md-border'])
|
||||
set('--sidebar-hover', hexWithAlpha(vars['--md-border'] ?? '#e1e4e8', 0.4))
|
||||
set('--bg-secondary', hexWithAlpha(vars['--md-text'] ?? '#333', 0.04))
|
||||
set('--bg-tertiary', hexWithAlpha(vars['--md-text'] ?? '#333', 0.08))
|
||||
set('--border-light', hexWithAlpha(vars['--md-border'] ?? '#e1e4e8', 0.5))
|
||||
set('--search-bg', hexWithAlpha(vars['--md-text'] ?? '#333', 0.04))
|
||||
set('--search-border', vars['--md-border'])
|
||||
// 阴影根据主题适配
|
||||
const isDark = vars['--md-bg'] && vars['--md-bg'] !== '#ffffff' && vars['--md-bg'] !== '#fff'
|
||||
root.style.setProperty('--shadow', isDark
|
||||
? '0 1px 3px rgba(0,0,0,0.3)'
|
||||
: '0 1px 3px rgba(0,0,0,0.08)')
|
||||
root.style.setProperty('--shadow-lg', isDark
|
||||
? '0 4px 12px rgba(0,0,0,0.4)'
|
||||
: '0 4px 12px rgba(0,0,0,0.1)')
|
||||
} catch { /* 容错 */ }
|
||||
}
|
||||
|
||||
const THEME_TO_TOAST: Record<ThemeMode, string> = {
|
||||
light: 'light',
|
||||
dark: 'dark',
|
||||
warm: 'warm',
|
||||
}
|
||||
|
||||
/**
|
||||
* AR-04: 主题 hook
|
||||
* 不再独立加载设置(由 useSettingsInit 统一加载)
|
||||
* 仅负责主题切换时的同步和保存
|
||||
* 主题 hook — 三主题循环(亮色 → 暗色 → 暖色),
|
||||
* 应用颜色自动同步编辑器主题,保持一致。
|
||||
*/
|
||||
export function useTheme() {
|
||||
const darkMode = useEditorStore(s => s.darkMode)
|
||||
const toggleDarkMode = useEditorStore(s => s.toggleDarkMode)
|
||||
const themeMode = useEditorStore(s => s.themeMode)
|
||||
const cycleTheme = useEditorStore(s => s.cycleTheme)
|
||||
|
||||
// darkMode 变化时同步 class 并保存
|
||||
const handleCycleTheme = useCallback(() => {
|
||||
const next = cycleTheme()
|
||||
// 同步编辑器全局主题
|
||||
MeEditor.setTheme(next)
|
||||
// 同步 Toast 主题
|
||||
import('../lib/toast').then(({ MeToast }) => {
|
||||
MeToast.themes.switchTheme(THEME_TO_TOAST[next] ?? 'auto')
|
||||
})
|
||||
// 持久化
|
||||
settingsRepository.save({ themeMode: next })
|
||||
}, [cycleTheme])
|
||||
|
||||
// themeMode 变化时同步应用颜色
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', darkMode)
|
||||
settingsRepository.save({ darkMode })
|
||||
syncToastTheme(darkMode)
|
||||
}, [darkMode])
|
||||
// 暗色/暖色统一加 .dark class(兼容 global.css 中的 :root.dark 硬编码规则)
|
||||
document.documentElement.classList.toggle('dark', themeMode === 'dark' || themeMode === 'warm')
|
||||
syncAppColorsToEditor()
|
||||
}, [themeMode])
|
||||
|
||||
return { darkMode, toggleDarkMode }
|
||||
return { themeMode, cycleTheme: handleCycleTheme }
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ describe('editorStore', () => {
|
||||
beforeEach(() => {
|
||||
useEditorStore.setState({
|
||||
viewMode: 'editor',
|
||||
darkMode: false,
|
||||
themeMode: 'light',
|
||||
externallyModified: null,
|
||||
loadingStates: {},
|
||||
})
|
||||
@@ -24,29 +24,36 @@ describe('editorStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('setDarkMode', () => {
|
||||
it('should enable dark mode', () => {
|
||||
useEditorStore.getState().setDarkMode(true)
|
||||
expect(useEditorStore.getState().darkMode).toBe(true)
|
||||
describe('themeMode', () => {
|
||||
it('should default to light', () => {
|
||||
expect(useEditorStore.getState().themeMode).toBe('light')
|
||||
})
|
||||
|
||||
it('should disable dark mode', () => {
|
||||
useEditorStore.setState({ darkMode: true })
|
||||
useEditorStore.getState().setDarkMode(false)
|
||||
expect(useEditorStore.getState().darkMode).toBe(false)
|
||||
it('should set theme mode', () => {
|
||||
useEditorStore.getState().setThemeMode('dark')
|
||||
expect(useEditorStore.getState().themeMode).toBe('dark')
|
||||
})
|
||||
|
||||
it('should set warm theme', () => {
|
||||
useEditorStore.getState().setThemeMode('warm')
|
||||
expect(useEditorStore.getState().themeMode).toBe('warm')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleDarkMode', () => {
|
||||
it('should toggle from false to true', () => {
|
||||
useEditorStore.getState().toggleDarkMode()
|
||||
expect(useEditorStore.getState().darkMode).toBe(true)
|
||||
describe('cycleTheme', () => {
|
||||
it('should cycle light → dark', () => {
|
||||
expect(useEditorStore.getState().cycleTheme()).toBe('dark')
|
||||
expect(useEditorStore.getState().themeMode).toBe('dark')
|
||||
})
|
||||
|
||||
it('should toggle from true to false', () => {
|
||||
useEditorStore.setState({ darkMode: true })
|
||||
useEditorStore.getState().toggleDarkMode()
|
||||
expect(useEditorStore.getState().darkMode).toBe(false)
|
||||
it('should cycle dark → warm', () => {
|
||||
useEditorStore.setState({ themeMode: 'dark' })
|
||||
expect(useEditorStore.getState().cycleTheme()).toBe('warm')
|
||||
})
|
||||
|
||||
it('should cycle warm → light', () => {
|
||||
useEditorStore.setState({ themeMode: 'warm' })
|
||||
expect(useEditorStore.getState().cycleTheme()).toBe('light')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand'
|
||||
import type { ViewMode } from '../types/settings'
|
||||
import type { ViewMode, ThemeMode } from '../types/settings'
|
||||
import type { MarkdownEditor } from '@metona-team/metona-editor'
|
||||
|
||||
// Module-level getter for the MetonaEditor instance
|
||||
@@ -14,17 +14,19 @@ export function getMetonaEditor(): MarkdownEditor | null {
|
||||
return _getEditor ? _getEditor() : null
|
||||
}
|
||||
|
||||
const THEME_CYCLE: ThemeMode[] = ['light', 'dark', 'warm']
|
||||
|
||||
interface EditorState {
|
||||
viewMode: ViewMode
|
||||
darkMode: boolean
|
||||
themeMode: ThemeMode
|
||||
// AR-03: 外部修改检测状态,替代 DOM CustomEvent
|
||||
externallyModified: { filePath: string } | null
|
||||
// UX-02: 全局加载状态
|
||||
loadingStates: Record<string, boolean>
|
||||
|
||||
setViewMode: (mode: ViewMode) => void
|
||||
setDarkMode: (dark: boolean) => void
|
||||
toggleDarkMode: () => void
|
||||
setThemeMode: (mode: ThemeMode) => void
|
||||
cycleTheme: () => ThemeMode
|
||||
setExternallyModified: (info: { filePath: string } | null) => void
|
||||
// UX-02: 加载状态管理
|
||||
setLoading: (key: string, loading: boolean) => void
|
||||
@@ -33,13 +35,19 @@ interface EditorState {
|
||||
|
||||
export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
viewMode: 'editor',
|
||||
darkMode: false,
|
||||
themeMode: 'light',
|
||||
externallyModified: null,
|
||||
loadingStates: {},
|
||||
|
||||
setViewMode: (mode: ViewMode) => set({ viewMode: mode }),
|
||||
setDarkMode: (dark: boolean) => set({ darkMode: dark }),
|
||||
toggleDarkMode: () => set(state => ({ darkMode: !state.darkMode })),
|
||||
setThemeMode: (mode: ThemeMode) => set({ themeMode: mode }),
|
||||
cycleTheme: () => {
|
||||
const current = get().themeMode
|
||||
const idx = THEME_CYCLE.indexOf(current)
|
||||
const next = THEME_CYCLE[(idx + 1) % THEME_CYCLE.length]
|
||||
set({ themeMode: next })
|
||||
return next
|
||||
},
|
||||
setExternallyModified: (info: { filePath: string } | null) => set({ externallyModified: info }),
|
||||
setLoading: (key: string, loading: boolean) => set(state => ({
|
||||
loadingStates: { ...state.loadingStates, [key]: loading }
|
||||
|
||||
@@ -23,23 +23,6 @@
|
||||
--sidebar-active: var(--primary-light);
|
||||
--search-bg: var(--bg-secondary);
|
||||
--search-border: var(--border);
|
||||
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: "Cascadia Code", "Fira Code", "JetBrains Mono", Consolas, "Courier New", monospace;
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
--primary: #8ab4f8;
|
||||
--primary-light: #1a3a5c;
|
||||
--primary-dark: #aecbfa;
|
||||
--bg: #1e1e1e;
|
||||
--bg-secondary: #252526;
|
||||
--bg-tertiary: #2d2d2d;
|
||||
--text: #d4d4d4;
|
||||
--text-secondary: #9e9e9e;
|
||||
--text-tertiary: #6e6e6e;
|
||||
--border: #3e3e3e;
|
||||
--border-light: #333333;
|
||||
--code-bg: #2d2d2d;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
--font-ui: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
export type ThemeMode = 'light' | 'dark' | 'warm'
|
||||
export type ViewMode = 'editor' | 'preview' | 'source'
|
||||
|
||||
export interface Settings {
|
||||
darkMode: boolean
|
||||
themeMode: ThemeMode
|
||||
viewMode: ViewMode
|
||||
sidebarCollapsed: boolean
|
||||
sidebarWidth: number
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
darkMode: false,
|
||||
themeMode: 'light',
|
||||
viewMode: 'editor',
|
||||
sidebarCollapsed: false,
|
||||
sidebarWidth: 240
|
||||
|
||||
Reference in New Issue
Block a user