release: v0.5.0 — 升级 MetonaEditor 0.4.0 / MetonaToast 0.5.0,数据库迁移 MetonaSqlark 0.4.1
- 编辑器升级 0.4.0:TypeScript 全模块重构、启用浮动格式工具栏 - 通知升级 0.5.0:钩子系统、新增动画 - 移除 Dexie.js,迁移至 MetonaSqlark 0.4.1(AriaEngine:LSM-Tree + WAL + MVCC) - 数据库更换为 MarkLiteV2,旧 Dexie 数据放弃 - 修复 Editor 组件无条件重渲染(getActiveTab 返回新引用) - 更新关于弹框、README、DESIGN 文档
This commit is contained in:
+64
-64
@@ -1,64 +1,64 @@
|
||||
import { contextBridge, ipcRenderer, shell } from 'electron'
|
||||
import { IPC_CHANNELS } from '../shared/ipc-channels'
|
||||
import type { ElectronAPI } from '../renderer/types/ipc'
|
||||
|
||||
// C-02: 运行时实现受 ElectronAPI 类型约束,编译期保证 preload 与渲染进程契约一致
|
||||
const api: ElectronAPI = {
|
||||
// File operations
|
||||
openFile: () => ipcRenderer.invoke(IPC_CHANNELS.DIALOG_OPEN_FILE),
|
||||
readFile: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_READ, filePath),
|
||||
saveFile: (data) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE, data),
|
||||
saveFileAs: (data) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE_AS, data),
|
||||
getCurrentPath: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_GET_CURRENT_PATH),
|
||||
getFileStats: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_STATS, filePath),
|
||||
reloadFile: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_RELOAD),
|
||||
|
||||
// Tab management
|
||||
tabSwitched: (filePath: string | null) => ipcRenderer.invoke(IPC_CHANNELS.TAB_SWITCHED, filePath),
|
||||
|
||||
// Window control
|
||||
forceClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_FORCE_CLOSE),
|
||||
cancelClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_CANCEL_CLOSE),
|
||||
|
||||
// Shell — 仅允许 http/https 协议
|
||||
openExternal: (url: string) => {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
shell.openExternal(url)
|
||||
}
|
||||
} catch {
|
||||
// 无效 URL,忽略
|
||||
}
|
||||
},
|
||||
|
||||
// File Tree (Sidebar)
|
||||
readDirTree: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_READ_TREE, dirPath),
|
||||
openFolderDialog: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_OPEN_DIALOG),
|
||||
watchDir: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_WATCH, dirPath),
|
||||
unwatchDir: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_UNWATCH),
|
||||
|
||||
// Events from main process — 返回取消订阅函数
|
||||
onFileOpenInTab: (callback) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: { filePath: string; content: string }) => callback(data)
|
||||
ipcRenderer.on(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler)
|
||||
return () => { ipcRenderer.removeListener(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler) }
|
||||
},
|
||||
onExternalModification: (callback) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, filePath: string) => callback(filePath)
|
||||
ipcRenderer.on(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler)
|
||||
return () => { ipcRenderer.removeListener(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler) }
|
||||
},
|
||||
onDirChanged: (callback) => {
|
||||
const handler = () => callback()
|
||||
ipcRenderer.on(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler)
|
||||
return () => { ipcRenderer.removeListener(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler) }
|
||||
},
|
||||
onConfirmClose: (callback) => {
|
||||
const handler = () => callback()
|
||||
ipcRenderer.on(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler)
|
||||
return () => { ipcRenderer.removeListener(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler) }
|
||||
}
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', api)
|
||||
import { contextBridge, ipcRenderer, shell } from 'electron'
|
||||
import { IPC_CHANNELS } from '../shared/ipc-channels'
|
||||
import type { ElectronAPI } from '../renderer/types/ipc'
|
||||
|
||||
// C-02: 运行时实现受 ElectronAPI 类型约束,编译期保证 preload 与渲染进程契约一致
|
||||
const api: ElectronAPI = {
|
||||
// File operations
|
||||
openFile: () => ipcRenderer.invoke(IPC_CHANNELS.DIALOG_OPEN_FILE),
|
||||
readFile: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_READ, filePath),
|
||||
saveFile: (data) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE, data),
|
||||
saveFileAs: (data) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE_AS, data),
|
||||
getCurrentPath: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_GET_CURRENT_PATH),
|
||||
getFileStats: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_STATS, filePath),
|
||||
reloadFile: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_RELOAD),
|
||||
|
||||
// Tab management
|
||||
tabSwitched: (filePath: string | null) => ipcRenderer.invoke(IPC_CHANNELS.TAB_SWITCHED, filePath),
|
||||
|
||||
// Window control
|
||||
forceClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_FORCE_CLOSE),
|
||||
cancelClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_CANCEL_CLOSE),
|
||||
|
||||
// Shell — 仅允许 http/https 协议
|
||||
openExternal: (url: string) => {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
shell.openExternal(url)
|
||||
}
|
||||
} catch {
|
||||
// 无效 URL,忽略
|
||||
}
|
||||
},
|
||||
|
||||
// File Tree (Sidebar)
|
||||
readDirTree: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_READ_TREE, dirPath),
|
||||
openFolderDialog: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_OPEN_DIALOG),
|
||||
watchDir: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_WATCH, dirPath),
|
||||
unwatchDir: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_UNWATCH),
|
||||
|
||||
// Events from main process — 返回取消订阅函数
|
||||
onFileOpenInTab: (callback) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: { filePath: string; content: string }) => callback(data)
|
||||
ipcRenderer.on(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler)
|
||||
return () => { ipcRenderer.removeListener(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler) }
|
||||
},
|
||||
onExternalModification: (callback) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, filePath: string) => callback(filePath)
|
||||
ipcRenderer.on(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler)
|
||||
return () => { ipcRenderer.removeListener(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler) }
|
||||
},
|
||||
onDirChanged: (callback) => {
|
||||
const handler = () => callback()
|
||||
ipcRenderer.on(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler)
|
||||
return () => { ipcRenderer.removeListener(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler) }
|
||||
},
|
||||
onConfirmClose: (callback) => {
|
||||
const handler = () => callback()
|
||||
ipcRenderer.on(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler)
|
||||
return () => { ipcRenderer.removeListener(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler) }
|
||||
}
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', api)
|
||||
|
||||
Vendored
+16
-16
@@ -1,16 +1,16 @@
|
||||
/**
|
||||
* DX-02: Preload 类型安全声明
|
||||
* ElectronAPI 类型现在由 ../renderer/types/ipc.ts 集中定义,
|
||||
* preload/index.ts 引入该类型做编译期契约检查。
|
||||
* 本文件仅保留全局 Window 增强声明。
|
||||
*/
|
||||
|
||||
import type { ElectronAPI } from '../renderer/types/ipc'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
/**
|
||||
* DX-02: Preload 类型安全声明
|
||||
* ElectronAPI 类型现在由 ../renderer/types/ipc.ts 集中定义,
|
||||
* preload/index.ts 引入该类型做编译期契约检查。
|
||||
* 本文件仅保留全局 Window 增强声明。
|
||||
*/
|
||||
|
||||
import type { ElectronAPI } from '../renderer/types/ipc'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
|
||||
Vendored
+14
-14
@@ -1,14 +1,14 @@
|
||||
declare module '*.png' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
|
||||
declare module '*.svg' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
|
||||
declare module '*.ico' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
declare module '*.png' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
|
||||
declare module '*.svg' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
|
||||
declare module '*.ico' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDia
|
||||
<span>搜索替换</span>
|
||||
<span>文件树</span>
|
||||
<span>文档大纲</span>
|
||||
<span>浮动格式栏</span>
|
||||
<span>状态持久化</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,6 +45,7 @@ export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDia
|
||||
<span>git.metona.cn/MetonaTeam/MarkLite</span>
|
||||
</a>
|
||||
<p>基于 Electron + React + TypeScript 构建</p>
|
||||
<p>MetonaEditor 0.4.0 · MetonaToast 0.5.0 · MetonaSqlark 0.4.1</p>
|
||||
<p className="about-copyright">© 2026 thzxx</p>
|
||||
</div>
|
||||
<button className="about-close-btn" onClick={onClose}>关闭</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import React, { useEffect, useMemo, useRef } from 'react'
|
||||
import MeEditor from '@metona-team/metona-editor'
|
||||
import type { MarkdownEditor } from '@metona-team/metona-editor'
|
||||
import mermaid from 'mermaid'
|
||||
@@ -8,7 +8,7 @@ import { settingsRepository } from '../../db/settingsRepository'
|
||||
import { renderMarkdownSync } from '../../lib/markdown'
|
||||
import type { ThemeMode } from '../../types/settings'
|
||||
|
||||
// v0.2.4: 这些类型不再作为命名导出暴露,本地声明以保持类型安全
|
||||
// v0.4.0: 这些类型不再作为命名导出暴露,本地声明以保持类型安全
|
||||
type EditMode = 'edit' | 'split' | 'preview'
|
||||
type ThemeName = 'light' | 'dark' | 'auto' | 'warm' | string
|
||||
|
||||
@@ -46,8 +46,14 @@ const EDITOR_PLUGINS: string[] = [
|
||||
* Editor 组件 — 基于 MetonaEditor 的 Markdown 编辑器。
|
||||
*/
|
||||
export const Editor = React.memo(function Editor({ themeMode, onAppSave }: EditorProps) {
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
// B-03: 用 activeTabId + tabs 推导 activeTab 而非 s.getActiveTab(),
|
||||
// 后者每次返回新对象引用导致 Zustand 无条件重渲染
|
||||
const activeTab = useMemo(
|
||||
() => tabs.find(t => t.id === activeTabId) ?? null,
|
||||
[tabs, activeTabId]
|
||||
)
|
||||
const updateTabContent = useTabStore(s => s.updateTabContent)
|
||||
const setModified = useTabStore(s => s.setModified)
|
||||
const updateTabScroll = useTabStore(s => s.updateTabScroll)
|
||||
@@ -90,6 +96,8 @@ export const Editor = React.memo(function Editor({ themeMode, onAppSave }: Edito
|
||||
autoBrackets: true,
|
||||
readOnly: viewMode === 'preview',
|
||||
plugins: EDITOR_PLUGINS,
|
||||
// v0.5.0: 启用 0.4.0 浮动格式工具栏(选中文本弹出格式化按钮)
|
||||
floatingToolbar: true,
|
||||
// v0.2.4 新增配置项
|
||||
syncScroll: true,
|
||||
wordWrap: true,
|
||||
@@ -151,7 +159,7 @@ export const Editor = React.memo(function Editor({ themeMode, onAppSave }: Edito
|
||||
setMetonaEditorGetter(() => editor)
|
||||
currentContentRef.current = activeTab?.content ?? ''
|
||||
|
||||
// v0.4.5: 绑定 afterRender → 触发 Mermaid 图表渲染
|
||||
// v0.5.0: 绑定 afterRender → 触发 Mermaid 图表渲染
|
||||
const renderMermaid = () => {
|
||||
try { mermaid.run({ querySelector: '.me-mermaid .mermaid' }) } catch { /* 容错 */ }
|
||||
}
|
||||
|
||||
+176
-176
@@ -1,176 +1,176 @@
|
||||
import React from 'react'
|
||||
import appIconUrl from '../assets/icon.png'
|
||||
|
||||
// 统一图标 Props
|
||||
interface IconProps {
|
||||
size?: number
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const defaultProps: Partial<IconProps> = { size: 18 }
|
||||
|
||||
// ===== 应用图标 =====
|
||||
export function AppIcon({ size = 80 }: IconProps) {
|
||||
return (
|
||||
<img src={appIconUrl} alt="MarkLite" width={size} height={size} draggable={false} />
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 工具栏图标 =====
|
||||
export function FolderOpen({ size = defaultProps.size }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 19a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v1"/>
|
||||
<path d="M20.5 15H5a2 2 0 0 0-2 2l1.5 7h18l2-7a2 2 0 0 0-2-2h-2.5z" fill="none"/>
|
||||
<path d="M12 11h4" strokeDasharray="2 2"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Save({ size = defaultProps.size }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
|
||||
<polyline points="17 21 17 13 7 13 7 21"/>
|
||||
<polyline points="7 3 7 8 15 8"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Moon({ size = defaultProps.size }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
<circle cx="19" cy="5" r="1" fill="currentColor" opacity="0.5"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Sun({ size = defaultProps.size }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<circle cx="12" cy="12" r="2" fill="currentColor" opacity="0.3"/>
|
||||
<line x1="12" y1="1" x2="12" y2="3"/>
|
||||
<line x1="12" y1="21" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="3" y2="12"/>
|
||||
<line x1="21" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 工具栏右侧图标 =====
|
||||
export function Gitee({ size = defaultProps.size }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.984 0C5.372 0 0 5.372 0 11.984c0 6.612 5.372 11.984 11.984 11.984s11.984-5.372 11.984-11.984C23.968 5.372 18.596 0 11.984 0zm6.78 18.272c-.224.448-.832.672-1.344.448l-3.584-1.792c-.448-.224-.672-.448-.672-.896v-4.704c0-.448.448-.896.896-.896h4.704c.448 0 .896.448.896.896v4.704c0 .896-.448 1.568-1.344 1.792l.448.64zm-6.112-2.688c-.896 0-1.568-.672-1.568-1.568s.672-1.568 1.568-1.568 1.568.672 1.568 1.568-.672 1.568-1.568 1.568z"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Info({ size = defaultProps.size }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 标签栏图标 =====
|
||||
export function Close({ size = 10 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Plus({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 侧边栏图标 =====
|
||||
export function Folder({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
<line x1="9" y1="13" x2="15" y2="13" opacity="0.4"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function File({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="8" y1="13" x2="16" y2="13" opacity="0.4"/>
|
||||
<line x1="8" y1="17" x2="13" y2="17" opacity="0.3"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChevronRight({ size = 10 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function FolderPlus({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
<line x1="12" y1="11" x2="12" y2="17"/>
|
||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 拖拽覆盖层图标 =====
|
||||
export function UploadCloud({ size = 64 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"/>
|
||||
<polyline points="12 16 12 8"/>
|
||||
<polyline points="8 12 12 8 16 12"/>
|
||||
<line x1="12" y1="16" x2="12" y2="22"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 欢迎屏幕图标 =====
|
||||
export function WelcomeFile({ size = 20 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
<line x1="12" y1="11" x2="12" y2="17"/>
|
||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function WelcomeNew({ size = 20 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="12" y1="18" x2="12" y2="12"/>
|
||||
<line x1="9" y1="15" x2="15" y2="15"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
import React from 'react'
|
||||
import appIconUrl from '../assets/icon.png'
|
||||
|
||||
// 统一图标 Props
|
||||
interface IconProps {
|
||||
size?: number
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const defaultProps: Partial<IconProps> = { size: 18 }
|
||||
|
||||
// ===== 应用图标 =====
|
||||
export function AppIcon({ size = 80 }: IconProps) {
|
||||
return (
|
||||
<img src={appIconUrl} alt="MarkLite" width={size} height={size} draggable={false} />
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 工具栏图标 =====
|
||||
export function FolderOpen({ size = defaultProps.size }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 19a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v1"/>
|
||||
<path d="M20.5 15H5a2 2 0 0 0-2 2l1.5 7h18l2-7a2 2 0 0 0-2-2h-2.5z" fill="none"/>
|
||||
<path d="M12 11h4" strokeDasharray="2 2"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Save({ size = defaultProps.size }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
|
||||
<polyline points="17 21 17 13 7 13 7 21"/>
|
||||
<polyline points="7 3 7 8 15 8"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Moon({ size = defaultProps.size }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
<circle cx="19" cy="5" r="1" fill="currentColor" opacity="0.5"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Sun({ size = defaultProps.size }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<circle cx="12" cy="12" r="2" fill="currentColor" opacity="0.3"/>
|
||||
<line x1="12" y1="1" x2="12" y2="3"/>
|
||||
<line x1="12" y1="21" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="3" y2="12"/>
|
||||
<line x1="21" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 工具栏右侧图标 =====
|
||||
export function Gitee({ size = defaultProps.size }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.984 0C5.372 0 0 5.372 0 11.984c0 6.612 5.372 11.984 11.984 11.984s11.984-5.372 11.984-11.984C23.968 5.372 18.596 0 11.984 0zm6.78 18.272c-.224.448-.832.672-1.344.448l-3.584-1.792c-.448-.224-.672-.448-.672-.896v-4.704c0-.448.448-.896.896-.896h4.704c.448 0 .896.448.896.896v4.704c0 .896-.448 1.568-1.344 1.792l.448.64zm-6.112-2.688c-.896 0-1.568-.672-1.568-1.568s.672-1.568 1.568-1.568 1.568.672 1.568 1.568-.672 1.568-1.568 1.568z"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Info({ size = defaultProps.size }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 标签栏图标 =====
|
||||
export function Close({ size = 10 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Plus({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 侧边栏图标 =====
|
||||
export function Folder({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
<line x1="9" y1="13" x2="15" y2="13" opacity="0.4"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function File({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="8" y1="13" x2="16" y2="13" opacity="0.4"/>
|
||||
<line x1="8" y1="17" x2="13" y2="17" opacity="0.3"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChevronRight({ size = 10 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function FolderPlus({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
<line x1="12" y1="11" x2="12" y2="17"/>
|
||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 拖拽覆盖层图标 =====
|
||||
export function UploadCloud({ size = 64 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"/>
|
||||
<polyline points="12 16 12 8"/>
|
||||
<polyline points="8 12 12 8 16 12"/>
|
||||
<line x1="12" y1="16" x2="12" y2="22"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 欢迎屏幕图标 =====
|
||||
export function WelcomeFile({ size = 20 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
<line x1="12" y1="11" x2="12" y2="17"/>
|
||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function WelcomeNew({ size = 20 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="12" y1="18" x2="12" y2="12"/>
|
||||
<line x1="9" y1="15" x2="15" y2="15"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,71 +1,71 @@
|
||||
import React, { memo } from 'react'
|
||||
import type { Heading } from './outlineUtils'
|
||||
|
||||
// --- Component ---
|
||||
|
||||
interface OutlinePanelProps {
|
||||
headings: Heading[]
|
||||
onNavigate: (heading: Heading, index: number) => void
|
||||
activeHeadingIndex: number | null
|
||||
}
|
||||
|
||||
interface OutlineItemProps {
|
||||
heading: Heading
|
||||
index: number
|
||||
isActive: boolean
|
||||
onNavigate: (heading: Heading, index: number) => void
|
||||
}
|
||||
|
||||
const OutlineItem = memo(function OutlineItem({
|
||||
heading,
|
||||
index,
|
||||
isActive,
|
||||
onNavigate
|
||||
}: OutlineItemProps) {
|
||||
return (
|
||||
<button
|
||||
className={`outline-item outline-level-${heading.level}${isActive ? ' active' : ''}`}
|
||||
onClick={() => onNavigate(heading, index)}
|
||||
title={heading.text}
|
||||
aria-label={`跳转到标题:${heading.text}`}
|
||||
style={{ paddingLeft: `${8 + (heading.level - 1) * 12}px` }}
|
||||
>
|
||||
<span className="outline-level-dot" />
|
||||
<span className="outline-item-text">{heading.text}</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
export const OutlinePanel = memo(function OutlinePanel({
|
||||
headings,
|
||||
onNavigate,
|
||||
activeHeadingIndex
|
||||
}: OutlinePanelProps) {
|
||||
if (headings.length === 0) {
|
||||
return (
|
||||
<div className="outline-panel" role="region" aria-label="文档大纲">
|
||||
<div className="outline-header">文档大纲</div>
|
||||
<div className="outline-empty">当前文档无标题</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="outline-panel" role="region" aria-label="文档大纲">
|
||||
<div className="outline-header">文档大纲</div>
|
||||
<div className="outline-list" role="list" aria-label="标题列表">
|
||||
{headings.map((h, i) => (
|
||||
<OutlineItem
|
||||
key={`${h.text}-${i}`}
|
||||
heading={h}
|
||||
index={i}
|
||||
isActive={i === activeHeadingIndex}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
OutlinePanel.displayName = 'OutlinePanel'
|
||||
import React, { memo } from 'react'
|
||||
import type { Heading } from './outlineUtils'
|
||||
|
||||
// --- Component ---
|
||||
|
||||
interface OutlinePanelProps {
|
||||
headings: Heading[]
|
||||
onNavigate: (heading: Heading, index: number) => void
|
||||
activeHeadingIndex: number | null
|
||||
}
|
||||
|
||||
interface OutlineItemProps {
|
||||
heading: Heading
|
||||
index: number
|
||||
isActive: boolean
|
||||
onNavigate: (heading: Heading, index: number) => void
|
||||
}
|
||||
|
||||
const OutlineItem = memo(function OutlineItem({
|
||||
heading,
|
||||
index,
|
||||
isActive,
|
||||
onNavigate
|
||||
}: OutlineItemProps) {
|
||||
return (
|
||||
<button
|
||||
className={`outline-item outline-level-${heading.level}${isActive ? ' active' : ''}`}
|
||||
onClick={() => onNavigate(heading, index)}
|
||||
title={heading.text}
|
||||
aria-label={`跳转到标题:${heading.text}`}
|
||||
style={{ paddingLeft: `${8 + (heading.level - 1) * 12}px` }}
|
||||
>
|
||||
<span className="outline-level-dot" />
|
||||
<span className="outline-item-text">{heading.text}</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
export const OutlinePanel = memo(function OutlinePanel({
|
||||
headings,
|
||||
onNavigate,
|
||||
activeHeadingIndex
|
||||
}: OutlinePanelProps) {
|
||||
if (headings.length === 0) {
|
||||
return (
|
||||
<div className="outline-panel" role="region" aria-label="文档大纲">
|
||||
<div className="outline-header">文档大纲</div>
|
||||
<div className="outline-empty">当前文档无标题</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="outline-panel" role="region" aria-label="文档大纲">
|
||||
<div className="outline-header">文档大纲</div>
|
||||
<div className="outline-list" role="list" aria-label="标题列表">
|
||||
{headings.map((h, i) => (
|
||||
<OutlineItem
|
||||
key={`${h.text}-${i}`}
|
||||
heading={h}
|
||||
index={i}
|
||||
isActive={i === activeHeadingIndex}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
OutlinePanel.displayName = 'OutlinePanel'
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { OutlinePanel } from './OutlinePanel'
|
||||
export { parseHeadings } from './outlineUtils'
|
||||
export type { Heading } from './outlineUtils'
|
||||
export { OutlinePanel } from './OutlinePanel'
|
||||
export { parseHeadings } from './outlineUtils'
|
||||
export type { Heading } from './outlineUtils'
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
export interface Heading {
|
||||
level: number
|
||||
text: string
|
||||
/** Position in document (character offset from markdown source) */
|
||||
pos: number
|
||||
}
|
||||
|
||||
const HEADING_RE = /^(#{1,6})\s+(.+)$/gm
|
||||
|
||||
/**
|
||||
* Parse headings from raw markdown content using regex.
|
||||
*/
|
||||
export function parseHeadings(markdown: string): Heading[] {
|
||||
const headings: Heading[] = []
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
// Reset regex state
|
||||
HEADING_RE.lastIndex = 0
|
||||
|
||||
while ((match = HEADING_RE.exec(markdown)) !== null) {
|
||||
const level = match[1].length
|
||||
const text = match[2].trim()
|
||||
headings.push({ level, text, pos: match.index })
|
||||
}
|
||||
|
||||
return headings
|
||||
}
|
||||
export interface Heading {
|
||||
level: number
|
||||
text: string
|
||||
/** Position in document (character offset from markdown source) */
|
||||
pos: number
|
||||
}
|
||||
|
||||
const HEADING_RE = /^(#{1,6})\s+(.+)$/gm
|
||||
|
||||
/**
|
||||
* Parse headings from raw markdown content using regex.
|
||||
*/
|
||||
export function parseHeadings(markdown: string): Heading[] {
|
||||
const headings: Heading[] = []
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
// Reset regex state
|
||||
HEADING_RE.lastIndex = 0
|
||||
|
||||
while ((match = HEADING_RE.exec(markdown)) !== null) {
|
||||
const level = match[1].length
|
||||
const text = match[2].trim()
|
||||
headings.push({ level, text, pos: match.index })
|
||||
}
|
||||
|
||||
return headings
|
||||
}
|
||||
|
||||
@@ -1,71 +1,71 @@
|
||||
import React from 'react'
|
||||
import { FolderOpen, Save, Moon, Sun, Info } from '../Icons'
|
||||
import type { ThemeMode } from '../../types/settings'
|
||||
|
||||
interface ToolbarProps {
|
||||
onOpen: () => void
|
||||
onSave: () => 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, 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="文件操作">
|
||||
<button className="toolbar-btn" onClick={onOpen} title="打开文件 (Ctrl+O)" aria-label="打开文件">
|
||||
<FolderOpen size={18} />
|
||||
<span>打开</span>
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={onSave} title="保存文件 (Ctrl+S)" aria-label="保存文件">
|
||||
<Save size={18} />
|
||||
<span>保存</span>
|
||||
</button>
|
||||
<div className="toolbar-divider" role="separator" />
|
||||
<button
|
||||
className={`toolbar-btn toolbar-autosave${isAutoSaving ? ' saving' : ''}`}
|
||||
onClick={onToggleAutoSave}
|
||||
title={isAutoSaving ? '正在自动保存...' : (autoSaveEnabled ? '自动保存已开启 — 点击关闭' : '自动保存已关闭 — 点击开启')}
|
||||
aria-label={isAutoSaving ? '正在自动保存' : (autoSaveEnabled ? '关闭自动保存' : '开启自动保存')}
|
||||
>
|
||||
<span>{isAutoSaving ? '保存中...' : (autoSaveEnabled ? '自动' : '手动')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="toolbar-right" role="group" aria-label="设置">
|
||||
<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} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Toolbar.displayName = 'Toolbar'
|
||||
import React from 'react'
|
||||
import { FolderOpen, Save, Moon, Sun, Info } from '../Icons'
|
||||
import type { ThemeMode } from '../../types/settings'
|
||||
|
||||
interface ToolbarProps {
|
||||
onOpen: () => void
|
||||
onSave: () => 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, 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="文件操作">
|
||||
<button className="toolbar-btn" onClick={onOpen} title="打开文件 (Ctrl+O)" aria-label="打开文件">
|
||||
<FolderOpen size={18} />
|
||||
<span>打开</span>
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={onSave} title="保存文件 (Ctrl+S)" aria-label="保存文件">
|
||||
<Save size={18} />
|
||||
<span>保存</span>
|
||||
</button>
|
||||
<div className="toolbar-divider" role="separator" />
|
||||
<button
|
||||
className={`toolbar-btn toolbar-autosave${isAutoSaving ? ' saving' : ''}`}
|
||||
onClick={onToggleAutoSave}
|
||||
title={isAutoSaving ? '正在自动保存...' : (autoSaveEnabled ? '自动保存已开启 — 点击关闭' : '自动保存已关闭 — 点击开启')}
|
||||
aria-label={isAutoSaving ? '正在自动保存' : (autoSaveEnabled ? '关闭自动保存' : '开启自动保存')}
|
||||
>
|
||||
<span>{isAutoSaving ? '保存中...' : (autoSaveEnabled ? '自动' : '手动')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="toolbar-right" role="group" aria-label="设置">
|
||||
<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} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Toolbar.displayName = 'Toolbar'
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import { db, type RecentFile } from './schema'
|
||||
import { type Table } from '@metona-team/metona-sqlark'
|
||||
import { getDb, type RecentFile } from './schema'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
|
||||
export const recentFilesRepository = {
|
||||
async add(filePath: string): Promise<void> {
|
||||
try {
|
||||
const existing: RecentFile | undefined = await db.recentFiles.where('filePath').equals(filePath).first()
|
||||
if (existing) {
|
||||
await db.recentFiles.update(existing.id!, { lastOpened: Date.now() })
|
||||
const db = await getDb()
|
||||
const tbl = db.table('recentFiles') as Table<RecentFile>
|
||||
const existing = await tbl.select().where({ filePath }).execute()
|
||||
if (existing.length > 0) {
|
||||
await tbl.update({ lastOpened: Date.now() }).where({ filePath }).execute()
|
||||
} else {
|
||||
await db.recentFiles.add({ filePath, lastOpened: Date.now() })
|
||||
await tbl.insert({ filePath, lastOpened: Date.now() })
|
||||
}
|
||||
// L-06: 清理超过 50 条的旧记录
|
||||
const all: RecentFile[] = await db.recentFiles.orderBy('lastOpened').reverse().toArray()
|
||||
const all = (await tbl.select().orderBy('lastOpened', 'desc').execute()) as RecentFile[]
|
||||
if (all.length > 50) {
|
||||
const toDelete = all.slice(50)
|
||||
await db.recentFiles.bulkDelete(toDelete.map((f: RecentFile) => f.id!))
|
||||
const toDelete = all.slice(50).map((f: RecentFile) => f.filePath)
|
||||
await tbl.delete().where({ filePath: { $in: toDelete } }).execute()
|
||||
}
|
||||
} catch (error) {
|
||||
logError('添加最近文件失败', error)
|
||||
@@ -23,11 +26,13 @@ export const recentFilesRepository = {
|
||||
|
||||
async getAll(limit: number = 20): Promise<string[]> {
|
||||
try {
|
||||
const files: RecentFile[] = await db.recentFiles
|
||||
.orderBy('lastOpened')
|
||||
.reverse()
|
||||
const db = await getDb()
|
||||
const files = (await db
|
||||
.table('recentFiles')
|
||||
.select(['filePath'])
|
||||
.orderBy('lastOpened', 'desc')
|
||||
.limit(limit)
|
||||
.toArray()
|
||||
.execute()) as RecentFile[]
|
||||
return files.map((f: RecentFile) => f.filePath)
|
||||
} catch (error) {
|
||||
logError('读取最近文件失败', error)
|
||||
@@ -37,7 +42,8 @@ export const recentFilesRepository = {
|
||||
|
||||
async remove(filePath: string): Promise<void> {
|
||||
try {
|
||||
await db.recentFiles.where('filePath').equals(filePath).delete()
|
||||
const db = await getDb()
|
||||
await db.table('recentFiles').delete().where({ filePath }).execute()
|
||||
} catch (error) {
|
||||
logError('删除最近文件失败', error)
|
||||
}
|
||||
@@ -45,7 +51,8 @@ export const recentFilesRepository = {
|
||||
|
||||
async clear(): Promise<void> {
|
||||
try {
|
||||
await db.recentFiles.clear()
|
||||
const db = await getDb()
|
||||
await db.table('recentFiles').clear()
|
||||
} catch (error) {
|
||||
logError('清空最近文件失败', error)
|
||||
}
|
||||
|
||||
+77
-18
@@ -1,6 +1,11 @@
|
||||
import Dexie, { type EntityTable } from 'dexie'
|
||||
import { create, type ColumnDef, type MetonaSqlark } from '@metona-team/metona-sqlark'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
|
||||
export interface TabSnapshot {
|
||||
// 注意: 使用 type 别名而非 interface —
|
||||
// sqlark 的 Table<T> 泛型约束 T & Record<string, unknown>,
|
||||
// type 别名具备隐式索引签名,interface 没有(TS 索引签名规则)
|
||||
|
||||
export type TabSnapshot = {
|
||||
id: string
|
||||
filePath: string | null
|
||||
content: string
|
||||
@@ -11,12 +16,12 @@ export interface TabSnapshot {
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface ActiveTabRecord {
|
||||
export type ActiveTabRecord = {
|
||||
id: string // 固定为 'current'
|
||||
activeTabId: string | null
|
||||
}
|
||||
|
||||
export interface SettingsRecord {
|
||||
export type SettingsRecord = {
|
||||
id: string // 固定为 'default'
|
||||
themeMode: 'light' | 'dark' | 'warm'
|
||||
viewMode: 'editor' | 'preview' | 'source'
|
||||
@@ -24,24 +29,78 @@ export interface SettingsRecord {
|
||||
sidebarWidth: number
|
||||
}
|
||||
|
||||
export interface RecentFile {
|
||||
id?: number
|
||||
export type RecentFile = {
|
||||
// v0.5.0: sqlark 无自增主键,改用 filePath 作主键(业务上天然唯一)
|
||||
filePath: string
|
||||
lastOpened: number
|
||||
}
|
||||
|
||||
const db = new Dexie('MarkLite') as Dexie & {
|
||||
tabSnapshots: EntityTable<TabSnapshot, 'id'>
|
||||
settings: EntityTable<SettingsRecord, 'id'>
|
||||
recentFiles: EntityTable<RecentFile, 'id'>
|
||||
activeTab: EntityTable<ActiveTabRecord, 'id'>
|
||||
// v0.5.0: 库名更换为 MarkLiteV2,与旧 Dexie 库(MarkLite)彻底隔离,旧数据已放弃
|
||||
// v0.5.0: 存储引擎选用 AriaEngine(自研 LSM-Tree + WAL + MVCC,对标 SQLite)
|
||||
const DB_NAME = 'MarkLiteV2'
|
||||
const DB_VERSION = 1
|
||||
|
||||
const TAB_SNAPSHOTS_COLUMNS: Record<string, ColumnDef> = {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
filePath: { type: 'string' },
|
||||
content: { type: 'string' },
|
||||
scrollTop: { type: 'number' },
|
||||
selectionStart: { type: 'number' },
|
||||
selectionEnd: { type: 'number' },
|
||||
isModified: { type: 'boolean' },
|
||||
updatedAt: { type: 'number', index: true },
|
||||
}
|
||||
|
||||
db.version(1).stores({
|
||||
tabSnapshots: 'id, filePath, updatedAt',
|
||||
settings: 'id',
|
||||
recentFiles: '++id, filePath, lastOpened',
|
||||
activeTab: 'id'
|
||||
})
|
||||
const SETTINGS_COLUMNS: Record<string, ColumnDef> = {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
themeMode: { type: 'string' },
|
||||
viewMode: { type: 'string' },
|
||||
sidebarCollapsed: { type: 'boolean' },
|
||||
sidebarWidth: { type: 'number' },
|
||||
}
|
||||
|
||||
export { db }
|
||||
const RECENT_FILES_COLUMNS: Record<string, ColumnDef> = {
|
||||
filePath: { type: 'string', primaryKey: true },
|
||||
lastOpened: { type: 'number', index: true },
|
||||
}
|
||||
|
||||
const ACTIVE_TAB_COLUMNS: Record<string, ColumnDef> = {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
activeTabId: { type: 'string' },
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.5.0: MetonaSqlark 初始化 — 懒加载单例。
|
||||
* create() 为异步,无法像 Dexie 那样模块顶层同步实例化;
|
||||
* 首次调用时创建,之后复用同一 Promise。
|
||||
*/
|
||||
let dbPromise: Promise<MetonaSqlark> | null = null
|
||||
|
||||
async function defineTablesIfNeeded(db: MetonaSqlark): Promise<void> {
|
||||
const tables = await db.getTableNames()
|
||||
if (!tables.includes('tabSnapshots')) await db.defineTable('tabSnapshots', TAB_SNAPSHOTS_COLUMNS)
|
||||
if (!tables.includes('settings')) await db.defineTable('settings', SETTINGS_COLUMNS)
|
||||
if (!tables.includes('recentFiles')) await db.defineTable('recentFiles', RECENT_FILES_COLUMNS)
|
||||
if (!tables.includes('activeTab')) await db.defineTable('activeTab', ACTIVE_TAB_COLUMNS)
|
||||
}
|
||||
|
||||
export function getDb(): Promise<MetonaSqlark> {
|
||||
if (!dbPromise) {
|
||||
dbPromise = (async () => {
|
||||
const db = await create({
|
||||
name: DB_NAME,
|
||||
mode: 'aria', // AriaEngine: LSM-Tree + WAL + MVCC 快照隔离
|
||||
diskEngine: 'indexeddb', // 底层存储后端(indexeddb | opfs | memory)
|
||||
version: DB_VERSION,
|
||||
onError: (err: Error) => logError('数据库错误', err),
|
||||
})
|
||||
await defineTablesIfNeeded(db)
|
||||
return db
|
||||
})()
|
||||
// 初始化失败时允许下次重试
|
||||
dbPromise.catch(() => {
|
||||
dbPromise = null
|
||||
})
|
||||
}
|
||||
return dbPromise
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { db, type SettingsRecord } from './schema'
|
||||
import { type Table } from '@metona-team/metona-sqlark'
|
||||
import { getDb, type SettingsRecord } from './schema'
|
||||
import { DEFAULT_SETTINGS, type Settings } from '../types/settings'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
|
||||
export const settingsRepository = {
|
||||
async load(): Promise<Settings> {
|
||||
try {
|
||||
const record: SettingsRecord | undefined = await db.settings.get('default')
|
||||
const db = await getDb()
|
||||
const rows = await db
|
||||
.table('settings')
|
||||
.select()
|
||||
.where({ id: 'default' })
|
||||
.execute()
|
||||
const record = rows[0] as SettingsRecord | undefined
|
||||
if (record) {
|
||||
return {
|
||||
themeMode: record.themeMode ?? DEFAULT_SETTINGS.themeMode,
|
||||
@@ -20,13 +27,20 @@ export const settingsRepository = {
|
||||
return { ...DEFAULT_SETTINGS }
|
||||
},
|
||||
|
||||
// C-01: 先 load 再 merge 再 put,避免部分字段丢失
|
||||
// C-01: 先 load 再 merge 再 upsert,避免部分字段丢失
|
||||
// M-04: 添加错误处理与日志
|
||||
async save(partial: Partial<Settings>): Promise<void> {
|
||||
try {
|
||||
const db = await getDb()
|
||||
const current: Settings = await this.load()
|
||||
const merged: SettingsRecord = { id: 'default', ...current, ...partial }
|
||||
await db.settings.put(merged)
|
||||
const tbl = db.table('settings') as Table<SettingsRecord>
|
||||
const rows = await tbl.select().where({ id: 'default' }).execute()
|
||||
if (rows.length > 0) {
|
||||
await tbl.update({ ...merged }).where({ id: 'default' }).execute()
|
||||
} else {
|
||||
await tbl.insert(merged)
|
||||
}
|
||||
} catch (error) {
|
||||
logError('保存设置失败', error)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { db, type TabSnapshot } from './schema'
|
||||
import { type Table } from '@metona-team/metona-sqlark'
|
||||
import { getDb, type TabSnapshot, type ActiveTabRecord } from './schema'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
|
||||
export const tabRepository = {
|
||||
async saveAll(tabs: TabSnapshot[]): Promise<void> {
|
||||
try {
|
||||
await db.transaction('rw', db.tabSnapshots, async () => {
|
||||
await db.tabSnapshots.clear()
|
||||
await db.tabSnapshots.bulkAdd(tabs)
|
||||
const db = await getDb()
|
||||
// v0.5.0: sqlark 事务 — 失败自动回滚(AriaEngine MVCC 快照隔离)
|
||||
await db.transaction(async (trx) => {
|
||||
await trx.table('tabSnapshots').clear()
|
||||
if (tabs.length > 0) {
|
||||
await trx.table('tabSnapshots').insertMany(tabs)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logError('保存标签快照失败', error)
|
||||
@@ -15,7 +20,13 @@ export const tabRepository = {
|
||||
|
||||
async loadAll(): Promise<TabSnapshot[]> {
|
||||
try {
|
||||
return await db.tabSnapshots.orderBy('updatedAt').toArray()
|
||||
const db = await getDb()
|
||||
const rows = await db
|
||||
.table('tabSnapshots')
|
||||
.select()
|
||||
.orderBy('updatedAt', 'asc')
|
||||
.execute()
|
||||
return rows as TabSnapshot[]
|
||||
} catch (error) {
|
||||
logError('加载标签快照失败', error)
|
||||
return []
|
||||
@@ -24,7 +35,8 @@ export const tabRepository = {
|
||||
|
||||
async clearAll(): Promise<void> {
|
||||
try {
|
||||
await db.tabSnapshots.clear()
|
||||
const db = await getDb()
|
||||
await db.table('tabSnapshots').clear()
|
||||
} catch (error) {
|
||||
logError('清空标签快照失败', error)
|
||||
}
|
||||
@@ -32,7 +44,14 @@ export const tabRepository = {
|
||||
|
||||
async saveActiveTabId(tabId: string | null): Promise<void> {
|
||||
try {
|
||||
await db.activeTab.put({ id: 'current', activeTabId: tabId })
|
||||
const db = await getDb()
|
||||
const tbl = db.table('activeTab') as Table<ActiveTabRecord>
|
||||
const rows = await tbl.select().where({ id: 'current' }).execute()
|
||||
if (rows.length > 0) {
|
||||
await tbl.update({ activeTabId: tabId }).where({ id: 'current' }).execute()
|
||||
} else {
|
||||
await tbl.insert({ id: 'current', activeTabId: tabId })
|
||||
}
|
||||
} catch (error) {
|
||||
logError('保存活动标签ID失败', error)
|
||||
}
|
||||
@@ -40,11 +59,16 @@ export const tabRepository = {
|
||||
|
||||
async loadActiveTabId(): Promise<string | null> {
|
||||
try {
|
||||
const record = await db.activeTab.get('current')
|
||||
return record?.activeTabId ?? null
|
||||
const db = await getDb()
|
||||
const rows = await db
|
||||
.table('activeTab')
|
||||
.select()
|
||||
.where({ id: 'current' })
|
||||
.execute()
|
||||
return (rows[0] as ActiveTabRecord | undefined)?.activeTabId ?? null
|
||||
} catch (error) {
|
||||
logError('加载活动标签ID失败', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,79 +1,79 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
|
||||
/**
|
||||
* D4: 基于视口的活跃标题追踪 hook
|
||||
* 在 preview 模式下监听目标容器的滚动事件,
|
||||
* 根据文档中标题元素的 offsetTop 判断当前可见的标题索引。
|
||||
*
|
||||
* 仅在目标容器 ref 存在时生效(preview 面板挂载后)。
|
||||
*
|
||||
* @param containerRef - 包含 Markdown 渲染结果的 DOM 元素 ref
|
||||
* @param headings - 解析出的标题列表
|
||||
* @returns - 当前活跃标题的索引(null 表示无法判定或不在范围内)
|
||||
*/
|
||||
export function useActiveHeading(
|
||||
containerRef: React.RefObject<HTMLElement | null>,
|
||||
headings: { level: number; text: string }[]
|
||||
): number | null {
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null)
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const container = containerRef.current
|
||||
if (!container || headings.length === 0) {
|
||||
setActiveIndex(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 收集容器内所有 h1-h6 元素的 offsetTop
|
||||
const headingElements = Array.from(
|
||||
container.querySelectorAll('h1, h2, h3, h4, h5, h6')
|
||||
) as HTMLElement[]
|
||||
|
||||
if (headingElements.length === 0) {
|
||||
setActiveIndex(null)
|
||||
return
|
||||
}
|
||||
|
||||
const scrollTop = container.scrollTop
|
||||
const containerHeight = container.clientHeight
|
||||
const threshold = scrollTop + containerHeight * 0.3 // 上方 30% 位置视为"到达"
|
||||
|
||||
let bestIndex: number | null = null
|
||||
|
||||
for (let i = 0; i < headingElements.length; i++) {
|
||||
const el = headingElements[i]
|
||||
// 使用容器顶部的相对偏移而非 getBoundingClientRect(滚动容器不是 window)
|
||||
const top = el.offsetTop - (container.offsetTop || 0)
|
||||
if (top <= threshold) {
|
||||
// 找到 headings 中匹配的索引
|
||||
const text = el.textContent?.trim() ?? ''
|
||||
const matchIdx = headings.findIndex(
|
||||
h => h.text.trim() === text && el.tagName.slice(-1) === String(h.level)
|
||||
)
|
||||
if (matchIdx >= 0) bestIndex = matchIdx
|
||||
}
|
||||
}
|
||||
|
||||
setActiveIndex(bestIndex)
|
||||
}, [containerRef, headings])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container || headings.length === 0) {
|
||||
setActiveIndex(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 监听滚动事件
|
||||
container.addEventListener('scroll', handleScroll, { passive: true })
|
||||
|
||||
// 初始计算
|
||||
handleScroll()
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
}, [containerRef, headings, handleScroll])
|
||||
|
||||
return activeIndex
|
||||
}
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
|
||||
/**
|
||||
* D4: 基于视口的活跃标题追踪 hook
|
||||
* 在 preview 模式下监听目标容器的滚动事件,
|
||||
* 根据文档中标题元素的 offsetTop 判断当前可见的标题索引。
|
||||
*
|
||||
* 仅在目标容器 ref 存在时生效(preview 面板挂载后)。
|
||||
*
|
||||
* @param containerRef - 包含 Markdown 渲染结果的 DOM 元素 ref
|
||||
* @param headings - 解析出的标题列表
|
||||
* @returns - 当前活跃标题的索引(null 表示无法判定或不在范围内)
|
||||
*/
|
||||
export function useActiveHeading(
|
||||
containerRef: React.RefObject<HTMLElement | null>,
|
||||
headings: { level: number; text: string }[]
|
||||
): number | null {
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null)
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const container = containerRef.current
|
||||
if (!container || headings.length === 0) {
|
||||
setActiveIndex(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 收集容器内所有 h1-h6 元素的 offsetTop
|
||||
const headingElements = Array.from(
|
||||
container.querySelectorAll('h1, h2, h3, h4, h5, h6')
|
||||
) as HTMLElement[]
|
||||
|
||||
if (headingElements.length === 0) {
|
||||
setActiveIndex(null)
|
||||
return
|
||||
}
|
||||
|
||||
const scrollTop = container.scrollTop
|
||||
const containerHeight = container.clientHeight
|
||||
const threshold = scrollTop + containerHeight * 0.3 // 上方 30% 位置视为"到达"
|
||||
|
||||
let bestIndex: number | null = null
|
||||
|
||||
for (let i = 0; i < headingElements.length; i++) {
|
||||
const el = headingElements[i]
|
||||
// 使用容器顶部的相对偏移而非 getBoundingClientRect(滚动容器不是 window)
|
||||
const top = el.offsetTop - (container.offsetTop || 0)
|
||||
if (top <= threshold) {
|
||||
// 找到 headings 中匹配的索引
|
||||
const text = el.textContent?.trim() ?? ''
|
||||
const matchIdx = headings.findIndex(
|
||||
h => h.text.trim() === text && el.tagName.slice(-1) === String(h.level)
|
||||
)
|
||||
if (matchIdx >= 0) bestIndex = matchIdx
|
||||
}
|
||||
}
|
||||
|
||||
setActiveIndex(bestIndex)
|
||||
}, [containerRef, headings])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container || headings.length === 0) {
|
||||
setActiveIndex(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 监听滚动事件
|
||||
container.addEventListener('scroll', handleScroll, { passive: true })
|
||||
|
||||
// 初始计算
|
||||
handleScroll()
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
}, [containerRef, headings, handleScroll])
|
||||
|
||||
return activeIndex
|
||||
}
|
||||
|
||||
+116
-116
@@ -1,116 +1,116 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useAutoSaveStore } from '../stores/autoSaveStore'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
|
||||
/** Debounce delay for auto-save (ms) */
|
||||
const AUTO_SAVE_DELAY = 2000
|
||||
|
||||
/** 模块级 ref — 状态栏等外部组件通过此函数切换自动保存 */
|
||||
let _toggleAutoSave: (() => void) | null = null
|
||||
export function toggleAutoSaveExternal(): void {
|
||||
_toggleAutoSave?.()
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-save hook: subscribes to Zustand tab store, debounces content changes
|
||||
* and saves automatically after a pause in editing for file-backed tabs.
|
||||
*
|
||||
* Uses `useTabStore.getState()` and `.subscribe()` so it doesn't need to
|
||||
* re-render on every keystroke — the effect is triggered once and runs
|
||||
* reactively via the store subscription.
|
||||
*
|
||||
* Captures the tabId at debounce start so the timeout always saves the
|
||||
* correct tab even if the user switches tabs during the debounce window.
|
||||
*/
|
||||
export function useAutoSave(): { isAutoSaving: boolean; autoSaveEnabled: boolean; toggleAutoSave: () => void } {
|
||||
const [isAutoSaving, setIsAutoSaving] = useState(false)
|
||||
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const isSavingRef = useRef(false)
|
||||
const enabledRef = useRef(true)
|
||||
const mountedRef = useRef(true)
|
||||
|
||||
const toggleAutoSave = useCallback(() => {
|
||||
setAutoSaveEnabled(prev => {
|
||||
const next = !prev
|
||||
enabledRef.current = next
|
||||
if (!next && timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 暴露给外部(状态栏按钮)
|
||||
_toggleAutoSave = toggleAutoSave
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
|
||||
// Subscribe to Zustand store — fires on every state change
|
||||
const unsub = useTabStore.subscribe((state) => {
|
||||
if (!enabledRef.current) return
|
||||
|
||||
const tab = state.getActiveTab()
|
||||
if (!tab || !tab.filePath || !tab.isModified) return
|
||||
|
||||
// Capture the tabId so the timeout saves the correct tab
|
||||
const tabIdToSave = tab.id
|
||||
|
||||
// Debounce: clear previous timer, start new one
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
|
||||
timerRef.current = setTimeout(async () => {
|
||||
if (isSavingRef.current) return
|
||||
|
||||
// Re-read latest state; verify the captured tab still exists and is modified
|
||||
const currentState = useTabStore.getState()
|
||||
const tabToSave = currentState.tabs.find(t => t.id === tabIdToSave)
|
||||
if (!tabToSave || !tabToSave.filePath || !tabToSave.isModified) return
|
||||
|
||||
isSavingRef.current = true
|
||||
if (mountedRef.current) setIsAutoSaving(true)
|
||||
|
||||
try {
|
||||
if (!window.electronAPI) return
|
||||
const result = await window.electronAPI.saveFile({
|
||||
filePath: tabToSave.filePath,
|
||||
content: tabToSave.content
|
||||
})
|
||||
if (result.success && mountedRef.current) {
|
||||
currentState.setModified(tabToSave.id, false)
|
||||
}
|
||||
} catch (error) {
|
||||
logError('自动保存失败', error)
|
||||
} finally {
|
||||
if (mountedRef.current) setIsAutoSaving(false)
|
||||
isSavingRef.current = false
|
||||
}
|
||||
}, AUTO_SAVE_DELAY)
|
||||
})
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
unsub()
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 同步 autoSaveEnabled / isAutoSaving 到 autoSaveStore(供状态栏读取)
|
||||
useEffect(() => {
|
||||
useAutoSaveStore.getState().setState({ autoSaveEnabled })
|
||||
}, [autoSaveEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
useAutoSaveStore.getState().setState({ isAutoSaving })
|
||||
}, [isAutoSaving])
|
||||
|
||||
return { isAutoSaving, autoSaveEnabled, toggleAutoSave }
|
||||
}
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useAutoSaveStore } from '../stores/autoSaveStore'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
|
||||
/** Debounce delay for auto-save (ms) */
|
||||
const AUTO_SAVE_DELAY = 2000
|
||||
|
||||
/** 模块级 ref — 状态栏等外部组件通过此函数切换自动保存 */
|
||||
let _toggleAutoSave: (() => void) | null = null
|
||||
export function toggleAutoSaveExternal(): void {
|
||||
_toggleAutoSave?.()
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-save hook: subscribes to Zustand tab store, debounces content changes
|
||||
* and saves automatically after a pause in editing for file-backed tabs.
|
||||
*
|
||||
* Uses `useTabStore.getState()` and `.subscribe()` so it doesn't need to
|
||||
* re-render on every keystroke — the effect is triggered once and runs
|
||||
* reactively via the store subscription.
|
||||
*
|
||||
* Captures the tabId at debounce start so the timeout always saves the
|
||||
* correct tab even if the user switches tabs during the debounce window.
|
||||
*/
|
||||
export function useAutoSave(): { isAutoSaving: boolean; autoSaveEnabled: boolean; toggleAutoSave: () => void } {
|
||||
const [isAutoSaving, setIsAutoSaving] = useState(false)
|
||||
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const isSavingRef = useRef(false)
|
||||
const enabledRef = useRef(true)
|
||||
const mountedRef = useRef(true)
|
||||
|
||||
const toggleAutoSave = useCallback(() => {
|
||||
setAutoSaveEnabled(prev => {
|
||||
const next = !prev
|
||||
enabledRef.current = next
|
||||
if (!next && timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 暴露给外部(状态栏按钮)
|
||||
_toggleAutoSave = toggleAutoSave
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
|
||||
// Subscribe to Zustand store — fires on every state change
|
||||
const unsub = useTabStore.subscribe((state) => {
|
||||
if (!enabledRef.current) return
|
||||
|
||||
const tab = state.getActiveTab()
|
||||
if (!tab || !tab.filePath || !tab.isModified) return
|
||||
|
||||
// Capture the tabId so the timeout saves the correct tab
|
||||
const tabIdToSave = tab.id
|
||||
|
||||
// Debounce: clear previous timer, start new one
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
|
||||
timerRef.current = setTimeout(async () => {
|
||||
if (isSavingRef.current) return
|
||||
|
||||
// Re-read latest state; verify the captured tab still exists and is modified
|
||||
const currentState = useTabStore.getState()
|
||||
const tabToSave = currentState.tabs.find(t => t.id === tabIdToSave)
|
||||
if (!tabToSave || !tabToSave.filePath || !tabToSave.isModified) return
|
||||
|
||||
isSavingRef.current = true
|
||||
if (mountedRef.current) setIsAutoSaving(true)
|
||||
|
||||
try {
|
||||
if (!window.electronAPI) return
|
||||
const result = await window.electronAPI.saveFile({
|
||||
filePath: tabToSave.filePath,
|
||||
content: tabToSave.content
|
||||
})
|
||||
if (result.success && mountedRef.current) {
|
||||
currentState.setModified(tabToSave.id, false)
|
||||
}
|
||||
} catch (error) {
|
||||
logError('自动保存失败', error)
|
||||
} finally {
|
||||
if (mountedRef.current) setIsAutoSaving(false)
|
||||
isSavingRef.current = false
|
||||
}
|
||||
}, AUTO_SAVE_DELAY)
|
||||
})
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
unsub()
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 同步 autoSaveEnabled / isAutoSaving 到 autoSaveStore(供状态栏读取)
|
||||
useEffect(() => {
|
||||
useAutoSaveStore.getState().setState({ autoSaveEnabled })
|
||||
}, [autoSaveEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
useAutoSaveStore.getState().setState({ isAutoSaving })
|
||||
}, [isAutoSaving])
|
||||
|
||||
return { isAutoSaving, autoSaveEnabled, toggleAutoSave }
|
||||
}
|
||||
|
||||
@@ -1,74 +1,74 @@
|
||||
import { useEffect, useCallback } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { isAllowedFile } from '../lib/fileUtils'
|
||||
import { MAX_FILE_SIZE } from '../lib/constants'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
import { showToast } from '../lib/toast'
|
||||
|
||||
export function useDragDrop() {
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
|
||||
const handleDrop = useCallback(async (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const files = e.dataTransfer?.files
|
||||
if (!files) return
|
||||
|
||||
let rejected = 0
|
||||
for (const file of Array.from(files)) {
|
||||
// Electron adds a `path` property to File objects
|
||||
const electronFile = file as File & { path?: string }
|
||||
const filePath: string = electronFile.path || file.name
|
||||
if (!isAllowedFile(filePath)) {
|
||||
rejected++
|
||||
continue
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
showToast(`"${file.name}" 过大,暂不支持超过 20MB 的文件`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (window.electronAPI) {
|
||||
try {
|
||||
const result = await window.electronAPI.readFile(filePath)
|
||||
if (result.success && result.content !== undefined) {
|
||||
createTab(filePath, result.content)
|
||||
}
|
||||
} catch (error) {
|
||||
logError('拖拽读取文件失败', error)
|
||||
}
|
||||
} else {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev: ProgressEvent<FileReader>) => {
|
||||
const content = ev.target?.result as string
|
||||
createTab(file.name, content)
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
}
|
||||
|
||||
if (rejected > 0) {
|
||||
showToast(`仅支持 .md / .markdown / .txt 文件,已忽略 ${rejected} 个文件`)
|
||||
}
|
||||
}, [createTab])
|
||||
|
||||
useEffect(() => {
|
||||
const prevent = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
document.addEventListener('dragenter', prevent)
|
||||
document.addEventListener('dragleave', prevent)
|
||||
document.addEventListener('dragover', prevent)
|
||||
document.addEventListener('drop', handleDrop)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('dragenter', prevent)
|
||||
document.removeEventListener('dragleave', prevent)
|
||||
document.removeEventListener('dragover', prevent)
|
||||
document.removeEventListener('drop', handleDrop)
|
||||
}
|
||||
}, [handleDrop])
|
||||
}
|
||||
import { useEffect, useCallback } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { isAllowedFile } from '../lib/fileUtils'
|
||||
import { MAX_FILE_SIZE } from '../lib/constants'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
import { showToast } from '../lib/toast'
|
||||
|
||||
export function useDragDrop() {
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
|
||||
const handleDrop = useCallback(async (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const files = e.dataTransfer?.files
|
||||
if (!files) return
|
||||
|
||||
let rejected = 0
|
||||
for (const file of Array.from(files)) {
|
||||
// Electron adds a `path` property to File objects
|
||||
const electronFile = file as File & { path?: string }
|
||||
const filePath: string = electronFile.path || file.name
|
||||
if (!isAllowedFile(filePath)) {
|
||||
rejected++
|
||||
continue
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
showToast(`"${file.name}" 过大,暂不支持超过 20MB 的文件`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (window.electronAPI) {
|
||||
try {
|
||||
const result = await window.electronAPI.readFile(filePath)
|
||||
if (result.success && result.content !== undefined) {
|
||||
createTab(filePath, result.content)
|
||||
}
|
||||
} catch (error) {
|
||||
logError('拖拽读取文件失败', error)
|
||||
}
|
||||
} else {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev: ProgressEvent<FileReader>) => {
|
||||
const content = ev.target?.result as string
|
||||
createTab(file.name, content)
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
}
|
||||
|
||||
if (rejected > 0) {
|
||||
showToast(`仅支持 .md / .markdown / .txt 文件,已忽略 ${rejected} 个文件`)
|
||||
}
|
||||
}, [createTab])
|
||||
|
||||
useEffect(() => {
|
||||
const prevent = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
document.addEventListener('dragenter', prevent)
|
||||
document.addEventListener('dragleave', prevent)
|
||||
document.addEventListener('dragover', prevent)
|
||||
document.addEventListener('drop', handleDrop)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('dragenter', prevent)
|
||||
document.removeEventListener('dragleave', prevent)
|
||||
document.removeEventListener('dragover', prevent)
|
||||
document.removeEventListener('drop', handleDrop)
|
||||
}
|
||||
}, [handleDrop])
|
||||
}
|
||||
|
||||
@@ -1,52 +1,52 @@
|
||||
import { useEffect, useCallback } from 'react'
|
||||
import { useSidebarStore } from '../stores/sidebarStore'
|
||||
import { useEditorStore } from '../stores/editorStore'
|
||||
|
||||
/**
|
||||
* AR-02: 从 Sidebar.tsx 提取的文件夹操作逻辑
|
||||
* UX-02: 添加目录加载 loading 状态
|
||||
*/
|
||||
export function useFolderOperations() {
|
||||
const rootPath = useSidebarStore(s => s.rootPath)
|
||||
const setRootPath = useSidebarStore(s => s.setRootPath)
|
||||
const setTree = useSidebarStore(s => s.setTree)
|
||||
const expandDirs = useSidebarStore(s => s.expandDirs)
|
||||
const setLoading = useEditorStore(s => s.setLoading)
|
||||
|
||||
const handleOpenFolder = useCallback(async () => {
|
||||
if (!window.electronAPI) return
|
||||
const dirPath = await window.electronAPI.openFolderDialog()
|
||||
if (!dirPath) return
|
||||
setRootPath(dirPath)
|
||||
expandDirs([dirPath])
|
||||
setLoading('dir-load', true)
|
||||
try {
|
||||
const dirTree = await window.electronAPI.readDirTree(dirPath)
|
||||
if (dirTree.success && dirTree.tree) {
|
||||
setTree(dirTree.tree)
|
||||
window.electronAPI.watchDir(dirPath)
|
||||
}
|
||||
} finally {
|
||||
setLoading('dir-load', false)
|
||||
}
|
||||
}, [setRootPath, setTree, expandDirs, setLoading])
|
||||
|
||||
const refreshTree = useCallback(async () => {
|
||||
if (!rootPath || !window.electronAPI) return
|
||||
setLoading('dir-load', true)
|
||||
try {
|
||||
const result = await window.electronAPI.readDirTree(rootPath)
|
||||
if (result.success && result.tree) setTree(result.tree)
|
||||
} finally {
|
||||
setLoading('dir-load', false)
|
||||
}
|
||||
}, [rootPath, setTree, setLoading])
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return
|
||||
const unsubscribe = window.electronAPI.onDirChanged(() => refreshTree())
|
||||
return unsubscribe
|
||||
}, [refreshTree])
|
||||
|
||||
return { handleOpenFolder }
|
||||
}
|
||||
import { useEffect, useCallback } from 'react'
|
||||
import { useSidebarStore } from '../stores/sidebarStore'
|
||||
import { useEditorStore } from '../stores/editorStore'
|
||||
|
||||
/**
|
||||
* AR-02: 从 Sidebar.tsx 提取的文件夹操作逻辑
|
||||
* UX-02: 添加目录加载 loading 状态
|
||||
*/
|
||||
export function useFolderOperations() {
|
||||
const rootPath = useSidebarStore(s => s.rootPath)
|
||||
const setRootPath = useSidebarStore(s => s.setRootPath)
|
||||
const setTree = useSidebarStore(s => s.setTree)
|
||||
const expandDirs = useSidebarStore(s => s.expandDirs)
|
||||
const setLoading = useEditorStore(s => s.setLoading)
|
||||
|
||||
const handleOpenFolder = useCallback(async () => {
|
||||
if (!window.electronAPI) return
|
||||
const dirPath = await window.electronAPI.openFolderDialog()
|
||||
if (!dirPath) return
|
||||
setRootPath(dirPath)
|
||||
expandDirs([dirPath])
|
||||
setLoading('dir-load', true)
|
||||
try {
|
||||
const dirTree = await window.electronAPI.readDirTree(dirPath)
|
||||
if (dirTree.success && dirTree.tree) {
|
||||
setTree(dirTree.tree)
|
||||
window.electronAPI.watchDir(dirPath)
|
||||
}
|
||||
} finally {
|
||||
setLoading('dir-load', false)
|
||||
}
|
||||
}, [setRootPath, setTree, expandDirs, setLoading])
|
||||
|
||||
const refreshTree = useCallback(async () => {
|
||||
if (!rootPath || !window.electronAPI) return
|
||||
setLoading('dir-load', true)
|
||||
try {
|
||||
const result = await window.electronAPI.readDirTree(rootPath)
|
||||
if (result.success && result.tree) setTree(result.tree)
|
||||
} finally {
|
||||
setLoading('dir-load', false)
|
||||
}
|
||||
}, [rootPath, setTree, setLoading])
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return
|
||||
const unsubscribe = window.electronAPI.onDirChanged(() => refreshTree())
|
||||
return unsubscribe
|
||||
}, [refreshTree])
|
||||
|
||||
return { handleOpenFolder }
|
||||
}
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { recentFilesRepository } from '../db/recentFilesRepository'
|
||||
|
||||
/**
|
||||
* 主进程事件注册 hook
|
||||
* 处理通过命令行或文件关联打开的文件
|
||||
*/
|
||||
export function useIpcListeners() {
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return
|
||||
const api = window.electronAPI
|
||||
const onOpen = (data: { filePath: string; content: string }) => {
|
||||
createTab(data.filePath, data.content)
|
||||
if (data.filePath) recentFilesRepository.add(data.filePath)
|
||||
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
||||
}
|
||||
return api.onFileOpenInTab(onOpen)
|
||||
}, [createTab])
|
||||
}
|
||||
import { useEffect } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { recentFilesRepository } from '../db/recentFilesRepository'
|
||||
|
||||
/**
|
||||
* 主进程事件注册 hook
|
||||
* 处理通过命令行或文件关联打开的文件
|
||||
*/
|
||||
export function useIpcListeners() {
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return
|
||||
const api = window.electronAPI
|
||||
const onOpen = (data: { filePath: string; content: string }) => {
|
||||
createTab(data.filePath, data.content)
|
||||
if (data.filePath) recentFilesRepository.add(data.filePath)
|
||||
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
||||
}
|
||||
return api.onFileOpenInTab(onOpen)
|
||||
}, [createTab])
|
||||
}
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
import { useEffect, useCallback } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
|
||||
/**
|
||||
* 全局键盘快捷键 hook。
|
||||
* MetonaEditor 内置工具栏处理格式化和模式切换(Ctrl+B/I/1/2/3),
|
||||
* 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 }
|
||||
|
||||
const tabState = useTabStore.getState()
|
||||
if (isCtrl && e.key === 't') { e.preventDefault(); tabState.createTab(null, ''); return }
|
||||
if (isCtrl && e.key === 'w') {
|
||||
e.preventDefault()
|
||||
if (tabState.activeTabId) tabState.closeTab(tabState.activeTabId)
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl+Tab / Ctrl+Shift+Tab — MRU 顺序切换
|
||||
if (isCtrl && e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
const { tabs, activeTabId, mruStack } = tabState
|
||||
if (tabs.length > 1) {
|
||||
if (mruStack.length > 0) {
|
||||
const targetId = mruStack[0]
|
||||
if (tabs.find(t => t.id === targetId)) {
|
||||
tabState.switchToTab(targetId)
|
||||
return
|
||||
}
|
||||
}
|
||||
const idx = tabs.findIndex(t => t.id === activeTabId)
|
||||
const next = e.shiftKey
|
||||
? (idx - 1 + tabs.length) % tabs.length
|
||||
: (idx + 1) % tabs.length
|
||||
tabState.switchToTab(tabs[next].id)
|
||||
}
|
||||
return
|
||||
}
|
||||
}, [handleOpenFile, handleSave, handleSaveAs])
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
return () => document.removeEventListener('keydown', handleKeydown)
|
||||
}, [handleKeydown])
|
||||
}
|
||||
import { useEffect, useCallback } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
|
||||
/**
|
||||
* 全局键盘快捷键 hook。
|
||||
* MetonaEditor 内置工具栏处理格式化和模式切换(Ctrl+B/I/1/2/3),
|
||||
* 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 }
|
||||
|
||||
const tabState = useTabStore.getState()
|
||||
if (isCtrl && e.key === 't') { e.preventDefault(); tabState.createTab(null, ''); return }
|
||||
if (isCtrl && e.key === 'w') {
|
||||
e.preventDefault()
|
||||
if (tabState.activeTabId) tabState.closeTab(tabState.activeTabId)
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl+Tab / Ctrl+Shift+Tab — MRU 顺序切换
|
||||
if (isCtrl && e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
const { tabs, activeTabId, mruStack } = tabState
|
||||
if (tabs.length > 1) {
|
||||
if (mruStack.length > 0) {
|
||||
const targetId = mruStack[0]
|
||||
if (tabs.find(t => t.id === targetId)) {
|
||||
tabState.switchToTab(targetId)
|
||||
return
|
||||
}
|
||||
}
|
||||
const idx = tabs.findIndex(t => t.id === activeTabId)
|
||||
const next = e.shiftKey
|
||||
? (idx - 1 + tabs.length) % tabs.length
|
||||
: (idx + 1) % tabs.length
|
||||
tabState.switchToTab(tabs[next].id)
|
||||
}
|
||||
return
|
||||
}
|
||||
}, [handleOpenFile, handleSave, handleSaveAs])
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
return () => document.removeEventListener('keydown', handleKeydown)
|
||||
}, [handleKeydown])
|
||||
}
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEditorStore } from '../stores/editorStore'
|
||||
import { useSidebarStore } from '../stores/sidebarStore'
|
||||
import { settingsRepository } from '../db/settingsRepository'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
|
||||
/**
|
||||
* AR-04: 统一设置加载 hook
|
||||
* 一次性从 IndexedDB 加载所有设置,分发到各 store,
|
||||
* 替代各 hook 各自独立加载设置的模式。
|
||||
*/
|
||||
export function useSettingsInit() {
|
||||
const setThemeMode = useEditorStore(s => s.setThemeMode)
|
||||
const setViewMode = useEditorStore(s => s.setViewMode)
|
||||
const isInitialized = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized.current) return
|
||||
isInitialized.current = true
|
||||
|
||||
settingsRepository.load()
|
||||
.then((settings) => {
|
||||
// 主题:优先使用保存的设置,否则跟随系统偏好
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
const themeMode = settings.themeMode ?? (prefersDark ? 'dark' : 'light')
|
||||
setThemeMode(themeMode)
|
||||
|
||||
// 视图模式
|
||||
setViewMode(settings.viewMode ?? 'editor')
|
||||
|
||||
// Sidebar 设置(直接分发,避免 sidebarStore 再次读取 IndexedDB)
|
||||
const sidebarStore = useSidebarStore.getState()
|
||||
if (!sidebarStore._loaded) {
|
||||
useSidebarStore.setState({
|
||||
isVisible: !settings.sidebarCollapsed,
|
||||
sidebarWidth: settings.sidebarWidth,
|
||||
_loaded: true
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
logError('加载设置失败', error)
|
||||
})
|
||||
}, [setThemeMode, setViewMode])
|
||||
|
||||
return { isInitialized }
|
||||
}
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEditorStore } from '../stores/editorStore'
|
||||
import { useSidebarStore } from '../stores/sidebarStore'
|
||||
import { settingsRepository } from '../db/settingsRepository'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
|
||||
/**
|
||||
* AR-04: 统一设置加载 hook
|
||||
* 一次性从 MetonaSqlark 加载所有设置,分发到各 store,
|
||||
* 替代各 hook 各自独立加载设置的模式。
|
||||
*/
|
||||
export function useSettingsInit() {
|
||||
const setThemeMode = useEditorStore(s => s.setThemeMode)
|
||||
const setViewMode = useEditorStore(s => s.setViewMode)
|
||||
const isInitialized = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized.current) return
|
||||
isInitialized.current = true
|
||||
|
||||
settingsRepository.load()
|
||||
.then((settings) => {
|
||||
// 主题:优先使用保存的设置,否则跟随系统偏好
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
const themeMode = settings.themeMode ?? (prefersDark ? 'dark' : 'light')
|
||||
setThemeMode(themeMode)
|
||||
|
||||
// 视图模式
|
||||
setViewMode(settings.viewMode ?? 'editor')
|
||||
|
||||
// Sidebar 设置(直接分发,避免 sidebarStore 再次读取 IndexedDB)
|
||||
const sidebarStore = useSidebarStore.getState()
|
||||
if (!sidebarStore._loaded) {
|
||||
useSidebarStore.setState({
|
||||
isVisible: !settings.sidebarCollapsed,
|
||||
sidebarWidth: settings.sidebarWidth,
|
||||
_loaded: true
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
logError('加载设置失败', error)
|
||||
})
|
||||
}, [setThemeMode, setViewMode])
|
||||
|
||||
return { isInitialized }
|
||||
}
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useSidebarStore } from '../stores/sidebarStore'
|
||||
|
||||
/**
|
||||
* AR-02: 从 Sidebar.tsx 提取的 resize 逻辑
|
||||
*
|
||||
* 拖拽过程中直接操作 DOM 宽度(避免每次 mousemove 触发 store 更新+重渲染),
|
||||
* 拖拽结束时将最终宽度同步到 sidebarStore(持久化到 IndexedDB)。
|
||||
*/
|
||||
export function useSidebarResize() {
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizing) return
|
||||
|
||||
const handleMouseMove = (e: MouseEvent): void => {
|
||||
if (sidebarRef.current) {
|
||||
const newWidth = Math.max(180, Math.min(500, e.clientX))
|
||||
sidebarRef.current.style.width = newWidth + 'px'
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = (): void => {
|
||||
// 拖拽结束时将最终宽度持久化到 store(→ IndexedDB)
|
||||
if (sidebarRef.current) {
|
||||
const width = sidebarRef.current.offsetWidth
|
||||
const clamped = Math.max(180, Math.min(500, width))
|
||||
useSidebarStore.getState().setSidebarWidth(clamped)
|
||||
}
|
||||
setIsResizing(false)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}, [isResizing])
|
||||
|
||||
const startResize = (): void => setIsResizing(true)
|
||||
|
||||
return { sidebarRef, isResizing, startResize }
|
||||
}
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useSidebarStore } from '../stores/sidebarStore'
|
||||
|
||||
/**
|
||||
* AR-02: 从 Sidebar.tsx 提取的 resize 逻辑
|
||||
*
|
||||
* 拖拽过程中直接操作 DOM 宽度(避免每次 mousemove 触发 store 更新+重渲染),
|
||||
* 拖拽结束时将最终宽度同步到 sidebarStore(持久化到 IndexedDB)。
|
||||
*/
|
||||
export function useSidebarResize() {
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizing) return
|
||||
|
||||
const handleMouseMove = (e: MouseEvent): void => {
|
||||
if (sidebarRef.current) {
|
||||
const newWidth = Math.max(180, Math.min(500, e.clientX))
|
||||
sidebarRef.current.style.width = newWidth + 'px'
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = (): void => {
|
||||
// 拖拽结束时将最终宽度持久化到 store(→ IndexedDB)
|
||||
if (sidebarRef.current) {
|
||||
const width = sidebarRef.current.offsetWidth
|
||||
const clamped = Math.max(180, Math.min(500, width))
|
||||
useSidebarStore.getState().setSidebarWidth(clamped)
|
||||
}
|
||||
setIsResizing(false)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}, [isResizing])
|
||||
|
||||
const startResize = (): void => setIsResizing(true)
|
||||
|
||||
return { sidebarRef, isResizing, startResize }
|
||||
}
|
||||
|
||||
@@ -1,90 +1,91 @@
|
||||
import { useEffect, useCallback } from 'react'
|
||||
import MeEditor from '@metona-team/metona-editor'
|
||||
import { useEditorStore } from '../stores/editorStore'
|
||||
import { settingsRepository } from '../db/settingsRepository'
|
||||
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',
|
||||
}
|
||||
|
||||
/**
|
||||
* 主题 hook — 三主题循环(亮色 → 暗色 → 暖色),
|
||||
* 应用颜色自动同步编辑器主题,保持一致。
|
||||
*/
|
||||
export function useTheme() {
|
||||
const themeMode = useEditorStore(s => s.themeMode)
|
||||
const cycleTheme = useEditorStore(s => s.cycleTheme)
|
||||
|
||||
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(() => {
|
||||
// 暗色/暖色统一加 .dark class(兼容 global.css 中的 :root.dark 硬编码规则)
|
||||
document.documentElement.classList.toggle('dark', themeMode === 'dark' || themeMode === 'warm')
|
||||
syncAppColorsToEditor()
|
||||
}, [themeMode])
|
||||
|
||||
return { themeMode, cycleTheme: handleCycleTheme }
|
||||
}
|
||||
import { useEffect, useCallback } from 'react'
|
||||
import MeEditor from '@metona-team/metona-editor'
|
||||
import { useEditorStore } from '../stores/editorStore'
|
||||
import { settingsRepository } from '../db/settingsRepository'
|
||||
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 {
|
||||
// v0.5.0: MeEditor.themes.exportCSSVars() 在 0.4.0 中仍保留(themeUtils 别名)
|
||||
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',
|
||||
}
|
||||
|
||||
/**
|
||||
* 主题 hook — 三主题循环(亮色 → 暗色 → 暖色),
|
||||
* 应用颜色自动同步编辑器主题,保持一致。
|
||||
*/
|
||||
export function useTheme() {
|
||||
const themeMode = useEditorStore(s => s.themeMode)
|
||||
const cycleTheme = useEditorStore(s => s.cycleTheme)
|
||||
|
||||
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(() => {
|
||||
// 暗色/暖色统一加 .dark class(兼容 global.css 中的 :root.dark 硬编码规则)
|
||||
document.documentElement.classList.toggle('dark', themeMode === 'dark' || themeMode === 'warm')
|
||||
syncAppColorsToEditor()
|
||||
}, [themeMode])
|
||||
|
||||
return { themeMode, cycleTheme: handleCycleTheme }
|
||||
}
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
import { useEffect, useCallback, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* 未保存提醒 hook
|
||||
* UX-01: 接受外部 confirm 函数,替代原生 confirm()
|
||||
*/
|
||||
export function useUnsavedWarning(
|
||||
hasUnsaved: () => boolean,
|
||||
confirmFn?: (message: string) => Promise<boolean>,
|
||||
// D5: 关闭前回调(flush 待保存数据)
|
||||
onBeforeForceClose?: () => Promise<void>
|
||||
) {
|
||||
const confirmFnRef = useRef(confirmFn)
|
||||
confirmFnRef.current = confirmFn
|
||||
const onBeforeForceCloseRef = useRef(onBeforeForceClose)
|
||||
onBeforeForceCloseRef.current = onBeforeForceClose
|
||||
|
||||
const doConfirm = useCallback(async (message: string): Promise<boolean> => {
|
||||
if (confirmFnRef.current) {
|
||||
return confirmFnRef.current(message)
|
||||
}
|
||||
// 后备方案:如果没有提供 confirmFn,使用 beforeunload 行为
|
||||
return true
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
if (hasUnsaved()) {
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handler)
|
||||
return () => window.removeEventListener('beforeunload', handler)
|
||||
}
|
||||
|
||||
const api = window.electronAPI
|
||||
|
||||
const unsubscribe = api.onConfirmClose(async () => {
|
||||
if (!hasUnsaved()) {
|
||||
await onBeforeForceCloseRef.current?.()
|
||||
api.forceClose()
|
||||
return
|
||||
}
|
||||
const shouldClose = await doConfirm('有文件尚未保存,确定要关闭吗?')
|
||||
if (shouldClose) {
|
||||
await onBeforeForceCloseRef.current?.()
|
||||
api.forceClose()
|
||||
} else {
|
||||
api.cancelClose()
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [hasUnsaved, doConfirm])
|
||||
}
|
||||
import { useEffect, useCallback, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* 未保存提醒 hook
|
||||
* UX-01: 接受外部 confirm 函数,替代原生 confirm()
|
||||
*/
|
||||
export function useUnsavedWarning(
|
||||
hasUnsaved: () => boolean,
|
||||
confirmFn?: (message: string) => Promise<boolean>,
|
||||
// D5: 关闭前回调(flush 待保存数据)
|
||||
onBeforeForceClose?: () => Promise<void>
|
||||
) {
|
||||
const confirmFnRef = useRef(confirmFn)
|
||||
confirmFnRef.current = confirmFn
|
||||
const onBeforeForceCloseRef = useRef(onBeforeForceClose)
|
||||
onBeforeForceCloseRef.current = onBeforeForceClose
|
||||
|
||||
const doConfirm = useCallback(async (message: string): Promise<boolean> => {
|
||||
if (confirmFnRef.current) {
|
||||
return confirmFnRef.current(message)
|
||||
}
|
||||
// 后备方案:如果没有提供 confirmFn,使用 beforeunload 行为
|
||||
return true
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
if (hasUnsaved()) {
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handler)
|
||||
return () => window.removeEventListener('beforeunload', handler)
|
||||
}
|
||||
|
||||
const api = window.electronAPI
|
||||
|
||||
const unsubscribe = api.onConfirmClose(async () => {
|
||||
if (!hasUnsaved()) {
|
||||
await onBeforeForceCloseRef.current?.()
|
||||
api.forceClose()
|
||||
return
|
||||
}
|
||||
const shouldClose = await doConfirm('有文件尚未保存,确定要关闭吗?')
|
||||
if (shouldClose) {
|
||||
await onBeforeForceCloseRef.current?.()
|
||||
api.forceClose()
|
||||
} else {
|
||||
api.cancelClose()
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [hasUnsaved, doConfirm])
|
||||
}
|
||||
|
||||
@@ -1,100 +1,100 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getFileName, isAllowedFile, getFileExtension } from '../fileUtils'
|
||||
|
||||
describe('fileUtils', () => {
|
||||
describe('getFileName', () => {
|
||||
it('should extract filename from Unix path', () => {
|
||||
expect(getFileName('/home/user/documents/file.md')).toBe('file.md')
|
||||
})
|
||||
|
||||
it('should extract filename from Windows path', () => {
|
||||
expect(getFileName('C:\\Users\\test\\file.md')).toBe('file.md')
|
||||
})
|
||||
|
||||
it('should return the input if it is already a filename', () => {
|
||||
expect(getFileName('file.md')).toBe('file.md')
|
||||
})
|
||||
|
||||
it('should handle path with trailing slash', () => {
|
||||
expect(getFileName('/path/to/dir/')).toBe('dir')
|
||||
})
|
||||
|
||||
|
||||
it('should handle path with trailing backslash', () => {
|
||||
expect(getFileName('C:\\Users\\test\\')).toBe('test')
|
||||
})
|
||||
|
||||
it('should handle mixed path separators', () => {
|
||||
expect(getFileName('C:\\Users/test\\file.md')).toBe('file.md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFileExtension', () => {
|
||||
it('should return lowercase extension', () => {
|
||||
expect(getFileExtension('file.MD')).toBe('.md')
|
||||
})
|
||||
|
||||
it('should return .markdown extension', () => {
|
||||
expect(getFileExtension('file.markdown')).toBe('.markdown')
|
||||
})
|
||||
|
||||
it('should return .txt extension', () => {
|
||||
expect(getFileExtension('notes.txt')).toBe('.txt')
|
||||
})
|
||||
|
||||
it('should return empty string for no extension', () => {
|
||||
expect(getFileExtension('Makefile')).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty string for hidden files starting with dot', () => {
|
||||
expect(getFileExtension('.gitignore')).toBe('')
|
||||
})
|
||||
|
||||
it('should extract extension from full path', () => {
|
||||
expect(getFileExtension('/home/user/file.md')).toBe('.md')
|
||||
})
|
||||
|
||||
it('should extract extension from Windows path', () => {
|
||||
expect(getFileExtension('C:\\Users\\test\\file.MARKDOWN')).toBe('.markdown')
|
||||
})
|
||||
|
||||
it('should handle file with multiple dots', () => {
|
||||
expect(getFileExtension('my.file.md')).toBe('.md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAllowedFile', () => {
|
||||
it('should return true for .md files', () => {
|
||||
expect(isAllowedFile('file.md')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for .markdown files', () => {
|
||||
expect(isAllowedFile('file.markdown')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for .txt files', () => {
|
||||
expect(isAllowedFile('file.txt')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for uppercase extensions', () => {
|
||||
expect(isAllowedFile('file.MD')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for .js files', () => {
|
||||
expect(isAllowedFile('file.js')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for .json files', () => {
|
||||
expect(isAllowedFile('file.json')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for files without extension', () => {
|
||||
expect(isAllowedFile('Makefile')).toBe(false)
|
||||
})
|
||||
|
||||
it('should work with full paths', () => {
|
||||
expect(isAllowedFile('/home/user/doc.md')).toBe(true)
|
||||
expect(isAllowedFile('/home/user/script.py')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getFileName, isAllowedFile, getFileExtension } from '../fileUtils'
|
||||
|
||||
describe('fileUtils', () => {
|
||||
describe('getFileName', () => {
|
||||
it('should extract filename from Unix path', () => {
|
||||
expect(getFileName('/home/user/documents/file.md')).toBe('file.md')
|
||||
})
|
||||
|
||||
it('should extract filename from Windows path', () => {
|
||||
expect(getFileName('C:\\Users\\test\\file.md')).toBe('file.md')
|
||||
})
|
||||
|
||||
it('should return the input if it is already a filename', () => {
|
||||
expect(getFileName('file.md')).toBe('file.md')
|
||||
})
|
||||
|
||||
it('should handle path with trailing slash', () => {
|
||||
expect(getFileName('/path/to/dir/')).toBe('dir')
|
||||
})
|
||||
|
||||
|
||||
it('should handle path with trailing backslash', () => {
|
||||
expect(getFileName('C:\\Users\\test\\')).toBe('test')
|
||||
})
|
||||
|
||||
it('should handle mixed path separators', () => {
|
||||
expect(getFileName('C:\\Users/test\\file.md')).toBe('file.md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFileExtension', () => {
|
||||
it('should return lowercase extension', () => {
|
||||
expect(getFileExtension('file.MD')).toBe('.md')
|
||||
})
|
||||
|
||||
it('should return .markdown extension', () => {
|
||||
expect(getFileExtension('file.markdown')).toBe('.markdown')
|
||||
})
|
||||
|
||||
it('should return .txt extension', () => {
|
||||
expect(getFileExtension('notes.txt')).toBe('.txt')
|
||||
})
|
||||
|
||||
it('should return empty string for no extension', () => {
|
||||
expect(getFileExtension('Makefile')).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty string for hidden files starting with dot', () => {
|
||||
expect(getFileExtension('.gitignore')).toBe('')
|
||||
})
|
||||
|
||||
it('should extract extension from full path', () => {
|
||||
expect(getFileExtension('/home/user/file.md')).toBe('.md')
|
||||
})
|
||||
|
||||
it('should extract extension from Windows path', () => {
|
||||
expect(getFileExtension('C:\\Users\\test\\file.MARKDOWN')).toBe('.markdown')
|
||||
})
|
||||
|
||||
it('should handle file with multiple dots', () => {
|
||||
expect(getFileExtension('my.file.md')).toBe('.md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAllowedFile', () => {
|
||||
it('should return true for .md files', () => {
|
||||
expect(isAllowedFile('file.md')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for .markdown files', () => {
|
||||
expect(isAllowedFile('file.markdown')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for .txt files', () => {
|
||||
expect(isAllowedFile('file.txt')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for uppercase extensions', () => {
|
||||
expect(isAllowedFile('file.MD')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for .js files', () => {
|
||||
expect(isAllowedFile('file.js')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for .json files', () => {
|
||||
expect(isAllowedFile('file.json')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for files without extension', () => {
|
||||
expect(isAllowedFile('Makefile')).toBe(false)
|
||||
})
|
||||
|
||||
it('should work with full paths', () => {
|
||||
expect(isAllowedFile('/home/user/doc.md')).toBe(true)
|
||||
expect(isAllowedFile('/home/user/script.py')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { ALLOWED_EXTENSIONS } from './constants'
|
||||
|
||||
export function getFileName(filePath: string): string {
|
||||
// E2: 先剔除尾部分隔符再取最后段,避免 /a/b/ 返回 /a/b/
|
||||
const clean = filePath.replace(/[/\\]+$/, '')
|
||||
return clean.split(/[/\\]/).pop() || clean || filePath
|
||||
}
|
||||
|
||||
export function isAllowedFile(filePath: string): boolean {
|
||||
const ext = getFileExtension(filePath)
|
||||
return (ALLOWED_EXTENSIONS as readonly string[]).includes(ext)
|
||||
}
|
||||
|
||||
// L-07: 无扩展名文件正确返回空字符串
|
||||
export function getFileExtension(filePath: string): string {
|
||||
const name = filePath.split(/[/\\]/).pop() || filePath
|
||||
const dotIndex = name.lastIndexOf('.')
|
||||
if (dotIndex <= 0) return '' // 无扩展名或以 . 开头的隐藏文件
|
||||
return name.substring(dotIndex).toLowerCase()
|
||||
}
|
||||
import { ALLOWED_EXTENSIONS } from './constants'
|
||||
|
||||
export function getFileName(filePath: string): string {
|
||||
// E2: 先剔除尾部分隔符再取最后段,避免 /a/b/ 返回 /a/b/
|
||||
const clean = filePath.replace(/[/\\]+$/, '')
|
||||
return clean.split(/[/\\]/).pop() || clean || filePath
|
||||
}
|
||||
|
||||
export function isAllowedFile(filePath: string): boolean {
|
||||
const ext = getFileExtension(filePath)
|
||||
return (ALLOWED_EXTENSIONS as readonly string[]).includes(ext)
|
||||
}
|
||||
|
||||
// L-07: 无扩展名文件正确返回空字符串
|
||||
export function getFileExtension(filePath: string): string {
|
||||
const name = filePath.split(/[/\\]/).pop() || filePath
|
||||
const dotIndex = name.lastIndexOf('.')
|
||||
if (dotIndex <= 0) return '' // 无扩展名或以 . 开头的隐藏文件
|
||||
return name.substring(dotIndex).toLowerCase()
|
||||
}
|
||||
|
||||
+50
-50
@@ -1,50 +1,50 @@
|
||||
import MeToast from '@metona-team/metona-toast'
|
||||
|
||||
/**
|
||||
* MeToast 全局配置
|
||||
*
|
||||
* 替代原自研 Toast 组件(components/Toast),由 MeToast 接管全部通知渲染。
|
||||
* MeToast 自管理 DOM 与样式,无需在 React 树中挂载容器组件。
|
||||
*
|
||||
* v0.4.4: 升级 MeToast 0.2.1,安装 keyboard / accessibility 插件。
|
||||
*/
|
||||
MeToast.configure({
|
||||
position: 'top-right',
|
||||
duration: 3000,
|
||||
max: 5,
|
||||
theme: 'auto',
|
||||
animation: 'slide',
|
||||
pauseOnHover: true,
|
||||
closeOnClick: true,
|
||||
showProgress: true,
|
||||
draggable: true,
|
||||
locale: 'zh-CN',
|
||||
width: 360,
|
||||
})
|
||||
|
||||
// 安装内置插件
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks -- MeToast.use() 是插件安装方法,非 React Hook
|
||||
MeToast.use('keyboard') // ESC 关闭所有 Toast
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
MeToast.use('accessibility') // 屏幕阅读器实时朗读 Toast 内容
|
||||
|
||||
/** 与原 showToast 保持兼容的类型子集 */
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
/**
|
||||
* 显示 Toast 通知 — 与原 useToast().showToast 签名兼容。
|
||||
*
|
||||
* @param msg 消息内容
|
||||
* @param type 通知类型(默认 info)
|
||||
* @param duration 显示时长(ms),省略则用全局默认值
|
||||
*/
|
||||
export function showToast(msg: string, type: ToastType = 'info', duration?: number): void {
|
||||
MeToast[type](msg, duration !== undefined ? { duration } : undefined)
|
||||
}
|
||||
|
||||
/** 同步 MeToast 主题到当前暗色模式 */
|
||||
export function syncToastTheme(darkMode: boolean): void {
|
||||
MeToast.themes.switchTheme(darkMode ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
export { MeToast }
|
||||
import MeToast from '@metona-team/metona-toast'
|
||||
|
||||
/**
|
||||
* MeToast 全局配置
|
||||
*
|
||||
* 替代原自研 Toast 组件(components/Toast),由 MeToast 接管全部通知渲染。
|
||||
* MeToast 自管理 DOM 与样式,无需在 React 树中挂载容器组件。
|
||||
*
|
||||
* v0.5.0: 升级 MeToast 0.5.0,安装 keyboard / accessibility 插件。
|
||||
*/
|
||||
MeToast.configure({
|
||||
position: 'top-right',
|
||||
duration: 3000,
|
||||
max: 5,
|
||||
theme: 'auto',
|
||||
animation: 'slide',
|
||||
pauseOnHover: true,
|
||||
closeOnClick: true,
|
||||
showProgress: true,
|
||||
draggable: true,
|
||||
locale: 'zh-CN',
|
||||
width: 360,
|
||||
})
|
||||
|
||||
// 安装内置插件
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks -- MeToast.use() 是插件安装方法,非 React Hook
|
||||
MeToast.use('keyboard') // ESC 关闭所有 Toast
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
MeToast.use('accessibility') // 屏幕阅读器实时朗读 Toast 内容
|
||||
|
||||
/** 与原 showToast 保持兼容的类型子集 */
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
/**
|
||||
* 显示 Toast 通知 — 与原 useToast().showToast 签名兼容。
|
||||
*
|
||||
* @param msg 消息内容
|
||||
* @param type 通知类型(默认 info)
|
||||
* @param duration 显示时长(ms),省略则用全局默认值
|
||||
*/
|
||||
export function showToast(msg: string, type: ToastType = 'info', duration?: number): void {
|
||||
MeToast[type](msg, duration !== undefined ? { duration } : undefined)
|
||||
}
|
||||
|
||||
/** 同步 MeToast 主题到当前暗色模式 */
|
||||
export function syncToastTheme(darkMode: boolean): void {
|
||||
MeToast.themes.switchTheme(darkMode ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
export { MeToast }
|
||||
|
||||
@@ -1,103 +1,103 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useEditorStore } from '../editorStore'
|
||||
|
||||
describe('editorStore', () => {
|
||||
beforeEach(() => {
|
||||
useEditorStore.setState({
|
||||
viewMode: 'editor',
|
||||
themeMode: 'light',
|
||||
externallyModified: null,
|
||||
loadingStates: {},
|
||||
})
|
||||
})
|
||||
|
||||
describe('setViewMode', () => {
|
||||
it('should set view mode to preview', () => {
|
||||
useEditorStore.getState().setViewMode('preview')
|
||||
expect(useEditorStore.getState().viewMode).toBe('preview')
|
||||
})
|
||||
|
||||
it('should set view mode to editor', () => {
|
||||
useEditorStore.setState({ viewMode: 'preview' })
|
||||
useEditorStore.getState().setViewMode('editor')
|
||||
expect(useEditorStore.getState().viewMode).toBe('editor')
|
||||
})
|
||||
})
|
||||
|
||||
describe('themeMode', () => {
|
||||
it('should default to light', () => {
|
||||
expect(useEditorStore.getState().themeMode).toBe('light')
|
||||
})
|
||||
|
||||
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('cycleTheme', () => {
|
||||
it('should cycle light → dark', () => {
|
||||
expect(useEditorStore.getState().cycleTheme()).toBe('dark')
|
||||
expect(useEditorStore.getState().themeMode).toBe('dark')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setExternallyModified', () => {
|
||||
it('should set externally modified info', () => {
|
||||
useEditorStore.getState().setExternallyModified({ filePath: '/test.md' })
|
||||
expect(useEditorStore.getState().externallyModified).toEqual({ filePath: '/test.md' })
|
||||
})
|
||||
|
||||
it('should clear externally modified info', () => {
|
||||
useEditorStore.setState({ externallyModified: { filePath: '/test.md' } })
|
||||
useEditorStore.getState().setExternallyModified(null)
|
||||
expect(useEditorStore.getState().externallyModified).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loading states', () => {
|
||||
it('should set loading state', () => {
|
||||
useEditorStore.getState().setLoading('file-open', true)
|
||||
expect(useEditorStore.getState().isLoading('file-open')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for unset loading key', () => {
|
||||
expect(useEditorStore.getState().isLoading('non-existent')).toBe(false)
|
||||
})
|
||||
|
||||
it('should update loading state to false', () => {
|
||||
useEditorStore.getState().setLoading('file-open', true)
|
||||
useEditorStore.getState().setLoading('file-open', false)
|
||||
expect(useEditorStore.getState().isLoading('file-open')).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle multiple independent loading states', () => {
|
||||
const { setLoading } = useEditorStore.getState()
|
||||
setLoading('file-open', true)
|
||||
setLoading('markdown-render', true)
|
||||
|
||||
const state = useEditorStore.getState()
|
||||
expect(state.isLoading('file-open')).toBe(true)
|
||||
expect(state.isLoading('markdown-render')).toBe(true)
|
||||
|
||||
setLoading('file-open', false)
|
||||
expect(useEditorStore.getState().isLoading('file-open')).toBe(false)
|
||||
expect(useEditorStore.getState().isLoading('markdown-render')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useEditorStore } from '../editorStore'
|
||||
|
||||
describe('editorStore', () => {
|
||||
beforeEach(() => {
|
||||
useEditorStore.setState({
|
||||
viewMode: 'editor',
|
||||
themeMode: 'light',
|
||||
externallyModified: null,
|
||||
loadingStates: {},
|
||||
})
|
||||
})
|
||||
|
||||
describe('setViewMode', () => {
|
||||
it('should set view mode to preview', () => {
|
||||
useEditorStore.getState().setViewMode('preview')
|
||||
expect(useEditorStore.getState().viewMode).toBe('preview')
|
||||
})
|
||||
|
||||
it('should set view mode to editor', () => {
|
||||
useEditorStore.setState({ viewMode: 'preview' })
|
||||
useEditorStore.getState().setViewMode('editor')
|
||||
expect(useEditorStore.getState().viewMode).toBe('editor')
|
||||
})
|
||||
})
|
||||
|
||||
describe('themeMode', () => {
|
||||
it('should default to light', () => {
|
||||
expect(useEditorStore.getState().themeMode).toBe('light')
|
||||
})
|
||||
|
||||
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('cycleTheme', () => {
|
||||
it('should cycle light → dark', () => {
|
||||
expect(useEditorStore.getState().cycleTheme()).toBe('dark')
|
||||
expect(useEditorStore.getState().themeMode).toBe('dark')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setExternallyModified', () => {
|
||||
it('should set externally modified info', () => {
|
||||
useEditorStore.getState().setExternallyModified({ filePath: '/test.md' })
|
||||
expect(useEditorStore.getState().externallyModified).toEqual({ filePath: '/test.md' })
|
||||
})
|
||||
|
||||
it('should clear externally modified info', () => {
|
||||
useEditorStore.setState({ externallyModified: { filePath: '/test.md' } })
|
||||
useEditorStore.getState().setExternallyModified(null)
|
||||
expect(useEditorStore.getState().externallyModified).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loading states', () => {
|
||||
it('should set loading state', () => {
|
||||
useEditorStore.getState().setLoading('file-open', true)
|
||||
expect(useEditorStore.getState().isLoading('file-open')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for unset loading key', () => {
|
||||
expect(useEditorStore.getState().isLoading('non-existent')).toBe(false)
|
||||
})
|
||||
|
||||
it('should update loading state to false', () => {
|
||||
useEditorStore.getState().setLoading('file-open', true)
|
||||
useEditorStore.getState().setLoading('file-open', false)
|
||||
expect(useEditorStore.getState().isLoading('file-open')).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle multiple independent loading states', () => {
|
||||
const { setLoading } = useEditorStore.getState()
|
||||
setLoading('file-open', true)
|
||||
setLoading('markdown-render', true)
|
||||
|
||||
const state = useEditorStore.getState()
|
||||
expect(state.isLoading('file-open')).toBe(true)
|
||||
expect(state.isLoading('markdown-render')).toBe(true)
|
||||
|
||||
setLoading('file-open', false)
|
||||
expect(useEditorStore.getState().isLoading('file-open')).toBe(false)
|
||||
expect(useEditorStore.getState().isLoading('markdown-render')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,317 +1,317 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock external dependencies before importing store
|
||||
vi.mock('../../db/tabRepository', () => ({
|
||||
tabRepository: {
|
||||
loadAll: vi.fn().mockResolvedValue([]),
|
||||
loadActiveTabId: vi.fn().mockResolvedValue(null),
|
||||
saveAll: vi.fn().mockResolvedValue(undefined),
|
||||
saveActiveTabId: vi.fn().mockResolvedValue(undefined),
|
||||
clearAll: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('nanoid', () => {
|
||||
let counter = 0
|
||||
return {
|
||||
nanoid: vi.fn(() => `test-id-${++counter}`),
|
||||
}
|
||||
})
|
||||
|
||||
import { useTabStore } from '../tabStore'
|
||||
|
||||
describe('tabStore', () => {
|
||||
beforeEach(() => {
|
||||
// Reset store state between tests
|
||||
useTabStore.setState({
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
mruStack: [],
|
||||
_loaded: false,
|
||||
})
|
||||
})
|
||||
|
||||
describe('createTab', () => {
|
||||
it('should create a new tab with default values', () => {
|
||||
const tab = useTabStore.getState().createTab()
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(tab.id).toBeTruthy()
|
||||
expect(tab.filePath).toBeNull()
|
||||
expect(tab.content).toBe('')
|
||||
expect(tab.isModified).toBe(false)
|
||||
expect(state.tabs).toHaveLength(1)
|
||||
expect(state.activeTabId).toBe(tab.id)
|
||||
})
|
||||
|
||||
it('should create a tab with file path and content', () => {
|
||||
const tab = useTabStore.getState().createTab('/test.md', '# Hello')
|
||||
|
||||
expect(tab.filePath).toBe('/test.md')
|
||||
expect(tab.content).toBe('# Hello')
|
||||
})
|
||||
|
||||
it('should switch to existing tab if same filePath is opened', () => {
|
||||
useTabStore.getState().createTab('/test.md', '# First')
|
||||
|
||||
// Create another tab to make it active
|
||||
useTabStore.getState().createTab('/other.md', '# Other')
|
||||
|
||||
// Now try to open the first file again
|
||||
const existing = useTabStore.getState().createTab('/test.md', '# Updated')
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.tabs).toHaveLength(2)
|
||||
expect(state.activeTabId).toBe(existing.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeTab', () => {
|
||||
it('should close a tab and update activeTabId', () => {
|
||||
const { createTab, closeTab } = useTabStore.getState()
|
||||
const tab1 = createTab('/file1.md')
|
||||
const tab2 = createTab('/file2.md')
|
||||
|
||||
closeTab(tab1.id)
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.tabs).toHaveLength(1)
|
||||
expect(state.tabs[0].id).toBe(tab2.id)
|
||||
expect(state.activeTabId).toBe(tab2.id)
|
||||
})
|
||||
|
||||
it('should set activeTabId to null when closing last tab', () => {
|
||||
const { createTab, closeTab } = useTabStore.getState()
|
||||
const tab = createTab('/file1.md')
|
||||
|
||||
closeTab(tab.id)
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.tabs).toHaveLength(0)
|
||||
expect(state.activeTabId).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle closing non-existent tab gracefully', () => {
|
||||
const { createTab, closeTab } = useTabStore.getState()
|
||||
createTab('/file1.md')
|
||||
|
||||
closeTab('non-existent')
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.tabs).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeOtherTabs', () => {
|
||||
it('should close all tabs except the specified one', () => {
|
||||
const { createTab, closeOtherTabs } = useTabStore.getState()
|
||||
const tab1 = createTab('/file1.md')
|
||||
createTab('/file2.md')
|
||||
createTab('/file3.md')
|
||||
|
||||
closeOtherTabs(tab1.id)
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.tabs).toHaveLength(1)
|
||||
expect(state.tabs[0].id).toBe(tab1.id)
|
||||
expect(state.activeTabId).toBe(tab1.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeAllTabs', () => {
|
||||
it('should close all tabs', () => {
|
||||
const { createTab, closeAllTabs } = useTabStore.getState()
|
||||
createTab('/file1.md')
|
||||
createTab('/file2.md')
|
||||
|
||||
closeAllTabs()
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.tabs).toHaveLength(0)
|
||||
expect(state.activeTabId).toBeNull()
|
||||
expect(state.mruStack).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeTabsToRight', () => {
|
||||
it('should close tabs to the right of the specified tab', () => {
|
||||
const { createTab, closeTabsToRight } = useTabStore.getState()
|
||||
const tab1 = createTab('/file1.md')
|
||||
createTab('/file2.md')
|
||||
createTab('/file3.md')
|
||||
|
||||
closeTabsToRight(tab1.id)
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.tabs).toHaveLength(1)
|
||||
expect(state.tabs[0].id).toBe(tab1.id)
|
||||
})
|
||||
|
||||
it('should keep active tab if it is in the remaining set', () => {
|
||||
const { createTab, switchToTab, closeTabsToRight } = useTabStore.getState()
|
||||
const tab1 = createTab('/file1.md')
|
||||
createTab('/file2.md')
|
||||
createTab('/file3.md')
|
||||
|
||||
switchToTab(tab1.id)
|
||||
closeTabsToRight(tab1.id)
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.activeTabId).toBe(tab1.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('switchToTab', () => {
|
||||
it('should switch active tab and update MRU stack', () => {
|
||||
const { createTab, switchToTab } = useTabStore.getState()
|
||||
const tab1 = createTab('/file1.md')
|
||||
const tab2 = createTab('/file2.md')
|
||||
|
||||
switchToTab(tab1.id)
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.activeTabId).toBe(tab1.id)
|
||||
expect(state.mruStack).toContain(tab2.id)
|
||||
})
|
||||
|
||||
it('should not update if switching to already active tab', () => {
|
||||
const { createTab, switchToTab } = useTabStore.getState()
|
||||
createTab('/file1.md')
|
||||
|
||||
const stateBefore = useTabStore.getState()
|
||||
switchToTab(stateBefore.activeTabId!)
|
||||
const stateAfter = useTabStore.getState()
|
||||
|
||||
expect(stateAfter.mruStack).toEqual(stateBefore.mruStack)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateTabContent', () => {
|
||||
it('should update content and mark as modified', () => {
|
||||
const { createTab, updateTabContent } = useTabStore.getState()
|
||||
const tab = createTab('/file.md', '# Old')
|
||||
|
||||
updateTabContent(tab.id, '# New Content')
|
||||
const state = useTabStore.getState()
|
||||
const updated = state.tabs.find(t => t.id === tab.id)!
|
||||
|
||||
expect(updated.content).toBe('# New Content')
|
||||
expect(updated.isModified).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getActiveTab', () => {
|
||||
it('should return the active tab', () => {
|
||||
const { createTab, getActiveTab } = useTabStore.getState()
|
||||
const tab = createTab('/file.md')
|
||||
|
||||
const active = getActiveTab()
|
||||
expect(active?.id).toBe(tab.id)
|
||||
})
|
||||
|
||||
it('should return null when no tabs exist', () => {
|
||||
const { getActiveTab } = useTabStore.getState()
|
||||
expect(getActiveTab()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('setModified', () => {
|
||||
it('should set modified flag on a tab', () => {
|
||||
const { createTab, setModified } = useTabStore.getState()
|
||||
const tab = createTab('/file.md')
|
||||
|
||||
setModified(tab.id, true)
|
||||
const state = useTabStore.getState()
|
||||
const updated = state.tabs.find(t => t.id === tab.id)!
|
||||
|
||||
expect(updated.isModified).toBe(true)
|
||||
|
||||
setModified(tab.id, false)
|
||||
const state2 = useTabStore.getState()
|
||||
const updated2 = state2.tabs.find(t => t.id === tab.id)!
|
||||
|
||||
expect(updated2.isModified).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateTabScroll', () => {
|
||||
it('should update scroll position', () => {
|
||||
const { createTab, updateTabScroll } = useTabStore.getState()
|
||||
const tab = createTab('/file.md')
|
||||
|
||||
updateTabScroll(tab.id, { scrollTop: 100, selectionStart: 10, selectionEnd: 20 })
|
||||
const state = useTabStore.getState()
|
||||
const updated = state.tabs.find(t => t.id === tab.id)!
|
||||
|
||||
expect(updated.scrollTop).toBe(100)
|
||||
expect(updated.selectionStart).toBe(10)
|
||||
expect(updated.selectionEnd).toBe(20)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateTabContent same-content guard (A3)', () => {
|
||||
it('should not mark as modified when content is unchanged', () => {
|
||||
const { createTab, updateTabContent } = useTabStore.getState()
|
||||
const tab = createTab('/file.md', '# Same Content')
|
||||
|
||||
updateTabContent(tab.id, '# Same Content')
|
||||
const state = useTabStore.getState()
|
||||
const updated = state.tabs.find(t => t.id === tab.id)!
|
||||
|
||||
expect(updated.content).toBe('# Same Content')
|
||||
expect(updated.isModified).toBe(false)
|
||||
})
|
||||
|
||||
it('should mark as modified when content changes', () => {
|
||||
const { createTab, updateTabContent } = useTabStore.getState()
|
||||
const tab = createTab('/file.md', '# Old')
|
||||
|
||||
updateTabContent(tab.id, '# New')
|
||||
const state = useTabStore.getState()
|
||||
const updated = state.tabs.find(t => t.id === tab.id)!
|
||||
|
||||
expect(updated.content).toBe('# New')
|
||||
expect(updated.isModified).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('moveTab (D1)', () => {
|
||||
it('should move tab to a new position', () => {
|
||||
const { createTab, moveTab } = useTabStore.getState()
|
||||
const tab1 = createTab('/file1.md')
|
||||
const tab2 = createTab('/file2.md')
|
||||
const tab3 = createTab('/file3.md')
|
||||
|
||||
// tab1 移到末尾:期望 [tab2, tab3, tab1]
|
||||
moveTab(tab1.id, 2)
|
||||
const state = useTabStore.getState()
|
||||
|
||||
// 用引用相等而非基于全局计数器 ID
|
||||
expect(state.tabs[0]).toBe(tab2)
|
||||
expect(state.tabs[1]).toBe(tab3)
|
||||
expect(state.tabs[2]).toBe(tab1)
|
||||
})
|
||||
|
||||
it('should handle moving to same position (no-op)', () => {
|
||||
const { createTab, moveTab } = useTabStore.getState()
|
||||
const tab1 = createTab('/file1.md')
|
||||
createTab('/file2.md')
|
||||
|
||||
moveTab(tab1.id, 0)
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.tabs[0].id).toBe(tab1.id)
|
||||
expect(state.tabs.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should handle moving non-existent tab gracefully', () => {
|
||||
const { createTab, moveTab } = useTabStore.getState()
|
||||
createTab('/file1.md')
|
||||
|
||||
moveTab('non-existent', 0)
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.tabs.length).toBe(1)
|
||||
})
|
||||
})
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock external dependencies before importing store
|
||||
vi.mock('../../db/tabRepository', () => ({
|
||||
tabRepository: {
|
||||
loadAll: vi.fn().mockResolvedValue([]),
|
||||
loadActiveTabId: vi.fn().mockResolvedValue(null),
|
||||
saveAll: vi.fn().mockResolvedValue(undefined),
|
||||
saveActiveTabId: vi.fn().mockResolvedValue(undefined),
|
||||
clearAll: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('nanoid', () => {
|
||||
let counter = 0
|
||||
return {
|
||||
nanoid: vi.fn(() => `test-id-${++counter}`),
|
||||
}
|
||||
})
|
||||
|
||||
import { useTabStore } from '../tabStore'
|
||||
|
||||
describe('tabStore', () => {
|
||||
beforeEach(() => {
|
||||
// Reset store state between tests
|
||||
useTabStore.setState({
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
mruStack: [],
|
||||
_loaded: false,
|
||||
})
|
||||
})
|
||||
|
||||
describe('createTab', () => {
|
||||
it('should create a new tab with default values', () => {
|
||||
const tab = useTabStore.getState().createTab()
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(tab.id).toBeTruthy()
|
||||
expect(tab.filePath).toBeNull()
|
||||
expect(tab.content).toBe('')
|
||||
expect(tab.isModified).toBe(false)
|
||||
expect(state.tabs).toHaveLength(1)
|
||||
expect(state.activeTabId).toBe(tab.id)
|
||||
})
|
||||
|
||||
it('should create a tab with file path and content', () => {
|
||||
const tab = useTabStore.getState().createTab('/test.md', '# Hello')
|
||||
|
||||
expect(tab.filePath).toBe('/test.md')
|
||||
expect(tab.content).toBe('# Hello')
|
||||
})
|
||||
|
||||
it('should switch to existing tab if same filePath is opened', () => {
|
||||
useTabStore.getState().createTab('/test.md', '# First')
|
||||
|
||||
// Create another tab to make it active
|
||||
useTabStore.getState().createTab('/other.md', '# Other')
|
||||
|
||||
// Now try to open the first file again
|
||||
const existing = useTabStore.getState().createTab('/test.md', '# Updated')
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.tabs).toHaveLength(2)
|
||||
expect(state.activeTabId).toBe(existing.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeTab', () => {
|
||||
it('should close a tab and update activeTabId', () => {
|
||||
const { createTab, closeTab } = useTabStore.getState()
|
||||
const tab1 = createTab('/file1.md')
|
||||
const tab2 = createTab('/file2.md')
|
||||
|
||||
closeTab(tab1.id)
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.tabs).toHaveLength(1)
|
||||
expect(state.tabs[0].id).toBe(tab2.id)
|
||||
expect(state.activeTabId).toBe(tab2.id)
|
||||
})
|
||||
|
||||
it('should set activeTabId to null when closing last tab', () => {
|
||||
const { createTab, closeTab } = useTabStore.getState()
|
||||
const tab = createTab('/file1.md')
|
||||
|
||||
closeTab(tab.id)
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.tabs).toHaveLength(0)
|
||||
expect(state.activeTabId).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle closing non-existent tab gracefully', () => {
|
||||
const { createTab, closeTab } = useTabStore.getState()
|
||||
createTab('/file1.md')
|
||||
|
||||
closeTab('non-existent')
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.tabs).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeOtherTabs', () => {
|
||||
it('should close all tabs except the specified one', () => {
|
||||
const { createTab, closeOtherTabs } = useTabStore.getState()
|
||||
const tab1 = createTab('/file1.md')
|
||||
createTab('/file2.md')
|
||||
createTab('/file3.md')
|
||||
|
||||
closeOtherTabs(tab1.id)
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.tabs).toHaveLength(1)
|
||||
expect(state.tabs[0].id).toBe(tab1.id)
|
||||
expect(state.activeTabId).toBe(tab1.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeAllTabs', () => {
|
||||
it('should close all tabs', () => {
|
||||
const { createTab, closeAllTabs } = useTabStore.getState()
|
||||
createTab('/file1.md')
|
||||
createTab('/file2.md')
|
||||
|
||||
closeAllTabs()
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.tabs).toHaveLength(0)
|
||||
expect(state.activeTabId).toBeNull()
|
||||
expect(state.mruStack).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeTabsToRight', () => {
|
||||
it('should close tabs to the right of the specified tab', () => {
|
||||
const { createTab, closeTabsToRight } = useTabStore.getState()
|
||||
const tab1 = createTab('/file1.md')
|
||||
createTab('/file2.md')
|
||||
createTab('/file3.md')
|
||||
|
||||
closeTabsToRight(tab1.id)
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.tabs).toHaveLength(1)
|
||||
expect(state.tabs[0].id).toBe(tab1.id)
|
||||
})
|
||||
|
||||
it('should keep active tab if it is in the remaining set', () => {
|
||||
const { createTab, switchToTab, closeTabsToRight } = useTabStore.getState()
|
||||
const tab1 = createTab('/file1.md')
|
||||
createTab('/file2.md')
|
||||
createTab('/file3.md')
|
||||
|
||||
switchToTab(tab1.id)
|
||||
closeTabsToRight(tab1.id)
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.activeTabId).toBe(tab1.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('switchToTab', () => {
|
||||
it('should switch active tab and update MRU stack', () => {
|
||||
const { createTab, switchToTab } = useTabStore.getState()
|
||||
const tab1 = createTab('/file1.md')
|
||||
const tab2 = createTab('/file2.md')
|
||||
|
||||
switchToTab(tab1.id)
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.activeTabId).toBe(tab1.id)
|
||||
expect(state.mruStack).toContain(tab2.id)
|
||||
})
|
||||
|
||||
it('should not update if switching to already active tab', () => {
|
||||
const { createTab, switchToTab } = useTabStore.getState()
|
||||
createTab('/file1.md')
|
||||
|
||||
const stateBefore = useTabStore.getState()
|
||||
switchToTab(stateBefore.activeTabId!)
|
||||
const stateAfter = useTabStore.getState()
|
||||
|
||||
expect(stateAfter.mruStack).toEqual(stateBefore.mruStack)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateTabContent', () => {
|
||||
it('should update content and mark as modified', () => {
|
||||
const { createTab, updateTabContent } = useTabStore.getState()
|
||||
const tab = createTab('/file.md', '# Old')
|
||||
|
||||
updateTabContent(tab.id, '# New Content')
|
||||
const state = useTabStore.getState()
|
||||
const updated = state.tabs.find(t => t.id === tab.id)!
|
||||
|
||||
expect(updated.content).toBe('# New Content')
|
||||
expect(updated.isModified).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getActiveTab', () => {
|
||||
it('should return the active tab', () => {
|
||||
const { createTab, getActiveTab } = useTabStore.getState()
|
||||
const tab = createTab('/file.md')
|
||||
|
||||
const active = getActiveTab()
|
||||
expect(active?.id).toBe(tab.id)
|
||||
})
|
||||
|
||||
it('should return null when no tabs exist', () => {
|
||||
const { getActiveTab } = useTabStore.getState()
|
||||
expect(getActiveTab()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('setModified', () => {
|
||||
it('should set modified flag on a tab', () => {
|
||||
const { createTab, setModified } = useTabStore.getState()
|
||||
const tab = createTab('/file.md')
|
||||
|
||||
setModified(tab.id, true)
|
||||
const state = useTabStore.getState()
|
||||
const updated = state.tabs.find(t => t.id === tab.id)!
|
||||
|
||||
expect(updated.isModified).toBe(true)
|
||||
|
||||
setModified(tab.id, false)
|
||||
const state2 = useTabStore.getState()
|
||||
const updated2 = state2.tabs.find(t => t.id === tab.id)!
|
||||
|
||||
expect(updated2.isModified).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateTabScroll', () => {
|
||||
it('should update scroll position', () => {
|
||||
const { createTab, updateTabScroll } = useTabStore.getState()
|
||||
const tab = createTab('/file.md')
|
||||
|
||||
updateTabScroll(tab.id, { scrollTop: 100, selectionStart: 10, selectionEnd: 20 })
|
||||
const state = useTabStore.getState()
|
||||
const updated = state.tabs.find(t => t.id === tab.id)!
|
||||
|
||||
expect(updated.scrollTop).toBe(100)
|
||||
expect(updated.selectionStart).toBe(10)
|
||||
expect(updated.selectionEnd).toBe(20)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateTabContent same-content guard (A3)', () => {
|
||||
it('should not mark as modified when content is unchanged', () => {
|
||||
const { createTab, updateTabContent } = useTabStore.getState()
|
||||
const tab = createTab('/file.md', '# Same Content')
|
||||
|
||||
updateTabContent(tab.id, '# Same Content')
|
||||
const state = useTabStore.getState()
|
||||
const updated = state.tabs.find(t => t.id === tab.id)!
|
||||
|
||||
expect(updated.content).toBe('# Same Content')
|
||||
expect(updated.isModified).toBe(false)
|
||||
})
|
||||
|
||||
it('should mark as modified when content changes', () => {
|
||||
const { createTab, updateTabContent } = useTabStore.getState()
|
||||
const tab = createTab('/file.md', '# Old')
|
||||
|
||||
updateTabContent(tab.id, '# New')
|
||||
const state = useTabStore.getState()
|
||||
const updated = state.tabs.find(t => t.id === tab.id)!
|
||||
|
||||
expect(updated.content).toBe('# New')
|
||||
expect(updated.isModified).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('moveTab (D1)', () => {
|
||||
it('should move tab to a new position', () => {
|
||||
const { createTab, moveTab } = useTabStore.getState()
|
||||
const tab1 = createTab('/file1.md')
|
||||
const tab2 = createTab('/file2.md')
|
||||
const tab3 = createTab('/file3.md')
|
||||
|
||||
// tab1 移到末尾:期望 [tab2, tab3, tab1]
|
||||
moveTab(tab1.id, 2)
|
||||
const state = useTabStore.getState()
|
||||
|
||||
// 用引用相等而非基于全局计数器 ID
|
||||
expect(state.tabs[0]).toBe(tab2)
|
||||
expect(state.tabs[1]).toBe(tab3)
|
||||
expect(state.tabs[2]).toBe(tab1)
|
||||
})
|
||||
|
||||
it('should handle moving to same position (no-op)', () => {
|
||||
const { createTab, moveTab } = useTabStore.getState()
|
||||
const tab1 = createTab('/file1.md')
|
||||
createTab('/file2.md')
|
||||
|
||||
moveTab(tab1.id, 0)
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.tabs[0].id).toBe(tab1.id)
|
||||
expect(state.tabs.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should handle moving non-existent tab gracefully', () => {
|
||||
const { createTab, moveTab } = useTabStore.getState()
|
||||
createTab('/file1.md')
|
||||
|
||||
moveTab('non-existent', 0)
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.tabs.length).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,24 +1,24 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
/**
|
||||
* 轻量 auto-save 状态 store
|
||||
*
|
||||
* 解耦 useAutoSave hook 与状态栏组件——状态栏的 auto-save 项通过
|
||||
* 本 store 读取状态,无需在 render 函数中重复调用 useAutoSave()。
|
||||
* useAutoSave hook 负责写入本 store。
|
||||
*/
|
||||
|
||||
interface AutoSaveState {
|
||||
isAutoSaving: boolean
|
||||
autoSaveEnabled: boolean
|
||||
}
|
||||
|
||||
interface AutoSaveStore extends AutoSaveState {
|
||||
setState: (partial: Partial<AutoSaveState>) => void
|
||||
}
|
||||
|
||||
export const useAutoSaveStore = create<AutoSaveStore>((set) => ({
|
||||
isAutoSaving: false,
|
||||
autoSaveEnabled: true,
|
||||
setState: (partial) => set(partial)
|
||||
}))
|
||||
import { create } from 'zustand'
|
||||
|
||||
/**
|
||||
* 轻量 auto-save 状态 store
|
||||
*
|
||||
* 解耦 useAutoSave hook 与状态栏组件——状态栏的 auto-save 项通过
|
||||
* 本 store 读取状态,无需在 render 函数中重复调用 useAutoSave()。
|
||||
* useAutoSave hook 负责写入本 store。
|
||||
*/
|
||||
|
||||
interface AutoSaveState {
|
||||
isAutoSaving: boolean
|
||||
autoSaveEnabled: boolean
|
||||
}
|
||||
|
||||
interface AutoSaveStore extends AutoSaveState {
|
||||
setState: (partial: Partial<AutoSaveState>) => void
|
||||
}
|
||||
|
||||
export const useAutoSaveStore = create<AutoSaveStore>((set) => ({
|
||||
isAutoSaving: false,
|
||||
autoSaveEnabled: true,
|
||||
setState: (partial) => set(partial)
|
||||
}))
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
import { create } from 'zustand'
|
||||
import type { ViewMode, ThemeMode } from '../types/settings'
|
||||
import type { MarkdownEditor } from '@metona-team/metona-editor'
|
||||
|
||||
// Module-level getter for the MetonaEditor instance
|
||||
// Used by OutlinePanel for heading navigation
|
||||
let _getEditor: (() => MarkdownEditor | null) | null = null
|
||||
|
||||
export function setMetonaEditorGetter(fn: () => MarkdownEditor | null) {
|
||||
_getEditor = fn
|
||||
}
|
||||
|
||||
export function getMetonaEditor(): MarkdownEditor | null {
|
||||
return _getEditor ? _getEditor() : null
|
||||
}
|
||||
|
||||
const THEME_CYCLE: ThemeMode[] = ['light', 'dark', 'warm']
|
||||
|
||||
interface EditorState {
|
||||
viewMode: ViewMode
|
||||
themeMode: ThemeMode
|
||||
// AR-03: 外部修改检测状态,替代 DOM CustomEvent
|
||||
externallyModified: { filePath: string } | null
|
||||
// UX-02: 全局加载状态
|
||||
loadingStates: Record<string, boolean>
|
||||
|
||||
setViewMode: (mode: ViewMode) => void
|
||||
setThemeMode: (mode: ThemeMode) => void
|
||||
cycleTheme: () => ThemeMode
|
||||
setExternallyModified: (info: { filePath: string } | null) => void
|
||||
// UX-02: 加载状态管理
|
||||
setLoading: (key: string, loading: boolean) => void
|
||||
isLoading: (key: string) => boolean
|
||||
}
|
||||
|
||||
export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
viewMode: 'editor',
|
||||
themeMode: 'light',
|
||||
externallyModified: null,
|
||||
loadingStates: {},
|
||||
|
||||
setViewMode: (mode: ViewMode) => set({ viewMode: mode }),
|
||||
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 }
|
||||
})),
|
||||
isLoading: (key: string) => get().loadingStates[key] ?? false
|
||||
}))
|
||||
import { create } from 'zustand'
|
||||
import type { ViewMode, ThemeMode } from '../types/settings'
|
||||
import type { MarkdownEditor } from '@metona-team/metona-editor'
|
||||
|
||||
// Module-level getter for the MetonaEditor instance
|
||||
// Used by OutlinePanel for heading navigation
|
||||
let _getEditor: (() => MarkdownEditor | null) | null = null
|
||||
|
||||
export function setMetonaEditorGetter(fn: () => MarkdownEditor | null) {
|
||||
_getEditor = fn
|
||||
}
|
||||
|
||||
export function getMetonaEditor(): MarkdownEditor | null {
|
||||
return _getEditor ? _getEditor() : null
|
||||
}
|
||||
|
||||
const THEME_CYCLE: ThemeMode[] = ['light', 'dark', 'warm']
|
||||
|
||||
interface EditorState {
|
||||
viewMode: ViewMode
|
||||
themeMode: ThemeMode
|
||||
// AR-03: 外部修改检测状态,替代 DOM CustomEvent
|
||||
externallyModified: { filePath: string } | null
|
||||
// UX-02: 全局加载状态
|
||||
loadingStates: Record<string, boolean>
|
||||
|
||||
setViewMode: (mode: ViewMode) => void
|
||||
setThemeMode: (mode: ThemeMode) => void
|
||||
cycleTheme: () => ThemeMode
|
||||
setExternallyModified: (info: { filePath: string } | null) => void
|
||||
// UX-02: 加载状态管理
|
||||
setLoading: (key: string, loading: boolean) => void
|
||||
isLoading: (key: string) => boolean
|
||||
}
|
||||
|
||||
export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
viewMode: 'editor',
|
||||
themeMode: 'light',
|
||||
externallyModified: null,
|
||||
loadingStates: {},
|
||||
|
||||
setViewMode: (mode: ViewMode) => set({ viewMode: mode }),
|
||||
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 }
|
||||
})),
|
||||
isLoading: (key: string) => get().loadingStates[key] ?? false
|
||||
}))
|
||||
|
||||
+51
-51
@@ -1,51 +1,51 @@
|
||||
import type {
|
||||
OpenFileResponse,
|
||||
ReadFileResult,
|
||||
SaveFilePayload,
|
||||
SaveFileResult,
|
||||
SaveAsPayload,
|
||||
ReloadFileResult,
|
||||
FileStatsResult,
|
||||
ReadDirTreeResult
|
||||
} from '../../shared/types'
|
||||
|
||||
export interface IpcInvokeMap {
|
||||
'dialog:openFile': [void, OpenFileResponse]
|
||||
'file:read': [string, ReadFileResult]
|
||||
'file:save': [SaveFilePayload, SaveFileResult]
|
||||
'file:saveAs': [SaveAsPayload, SaveFileResult]
|
||||
'file:getCurrentPath': [void, string | null]
|
||||
'file:stats': [string, FileStatsResult]
|
||||
'file:reload': [void, ReloadFileResult]
|
||||
'tab:switched': [string | null, void]
|
||||
'window:forceClose': [void, void]
|
||||
'window:cancelClose': [void, void]
|
||||
'dir:readTree': [string, ReadDirTreeResult]
|
||||
'dir:openDialog': [void, string | null]
|
||||
'dir:watch': [string, void]
|
||||
'dir:unwatch': [void, void]
|
||||
}
|
||||
|
||||
export type Unsubscribe = () => void
|
||||
|
||||
export interface ElectronAPI {
|
||||
openFile: () => Promise<OpenFileResponse>
|
||||
readFile: (filePath: string) => Promise<ReadFileResult>
|
||||
saveFile: (data: SaveFilePayload) => Promise<SaveFileResult>
|
||||
saveFileAs: (data: SaveAsPayload) => Promise<SaveFileResult>
|
||||
getCurrentPath: () => Promise<string | null>
|
||||
getFileStats: (filePath: string) => Promise<FileStatsResult>
|
||||
reloadFile: () => Promise<ReloadFileResult>
|
||||
tabSwitched: (filePath: string | null) => Promise<void>
|
||||
forceClose: () => Promise<void>
|
||||
cancelClose: () => Promise<void>
|
||||
openExternal: (url: string) => void
|
||||
readDirTree: (dirPath: string) => Promise<ReadDirTreeResult>
|
||||
openFolderDialog: () => Promise<string | null>
|
||||
watchDir: (dirPath: string) => Promise<void>
|
||||
unwatchDir: () => Promise<void>
|
||||
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => Unsubscribe
|
||||
onExternalModification: (callback: (filePath: string) => void) => Unsubscribe
|
||||
onDirChanged: (callback: () => void) => Unsubscribe
|
||||
onConfirmClose: (callback: () => void) => Unsubscribe
|
||||
}
|
||||
import type {
|
||||
OpenFileResponse,
|
||||
ReadFileResult,
|
||||
SaveFilePayload,
|
||||
SaveFileResult,
|
||||
SaveAsPayload,
|
||||
ReloadFileResult,
|
||||
FileStatsResult,
|
||||
ReadDirTreeResult
|
||||
} from '../../shared/types'
|
||||
|
||||
export interface IpcInvokeMap {
|
||||
'dialog:openFile': [void, OpenFileResponse]
|
||||
'file:read': [string, ReadFileResult]
|
||||
'file:save': [SaveFilePayload, SaveFileResult]
|
||||
'file:saveAs': [SaveAsPayload, SaveFileResult]
|
||||
'file:getCurrentPath': [void, string | null]
|
||||
'file:stats': [string, FileStatsResult]
|
||||
'file:reload': [void, ReloadFileResult]
|
||||
'tab:switched': [string | null, void]
|
||||
'window:forceClose': [void, void]
|
||||
'window:cancelClose': [void, void]
|
||||
'dir:readTree': [string, ReadDirTreeResult]
|
||||
'dir:openDialog': [void, string | null]
|
||||
'dir:watch': [string, void]
|
||||
'dir:unwatch': [void, void]
|
||||
}
|
||||
|
||||
export type Unsubscribe = () => void
|
||||
|
||||
export interface ElectronAPI {
|
||||
openFile: () => Promise<OpenFileResponse>
|
||||
readFile: (filePath: string) => Promise<ReadFileResult>
|
||||
saveFile: (data: SaveFilePayload) => Promise<SaveFileResult>
|
||||
saveFileAs: (data: SaveAsPayload) => Promise<SaveFileResult>
|
||||
getCurrentPath: () => Promise<string | null>
|
||||
getFileStats: (filePath: string) => Promise<FileStatsResult>
|
||||
reloadFile: () => Promise<ReloadFileResult>
|
||||
tabSwitched: (filePath: string | null) => Promise<void>
|
||||
forceClose: () => Promise<void>
|
||||
cancelClose: () => Promise<void>
|
||||
openExternal: (url: string) => void
|
||||
readDirTree: (dirPath: string) => Promise<ReadDirTreeResult>
|
||||
openFolderDialog: () => Promise<string | null>
|
||||
watchDir: (dirPath: string) => Promise<void>
|
||||
unwatchDir: () => Promise<void>
|
||||
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => Unsubscribe
|
||||
onExternalModification: (callback: (filePath: string) => void) => Unsubscribe
|
||||
onDirChanged: (callback: () => void) => Unsubscribe
|
||||
onConfirmClose: (callback: () => void) => Unsubscribe
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export type ThemeMode = 'light' | 'dark' | 'warm'
|
||||
export type ViewMode = 'editor' | 'preview' | 'source'
|
||||
|
||||
export interface Settings {
|
||||
themeMode: ThemeMode
|
||||
viewMode: ViewMode
|
||||
sidebarCollapsed: boolean
|
||||
sidebarWidth: number
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
themeMode: 'light',
|
||||
viewMode: 'editor',
|
||||
sidebarCollapsed: false,
|
||||
sidebarWidth: 240
|
||||
}
|
||||
export type ThemeMode = 'light' | 'dark' | 'warm'
|
||||
export type ViewMode = 'editor' | 'preview' | 'source'
|
||||
|
||||
export interface Settings {
|
||||
themeMode: ThemeMode
|
||||
viewMode: ViewMode
|
||||
sidebarCollapsed: boolean
|
||||
sidebarWidth: number
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
themeMode: 'light',
|
||||
viewMode: 'editor',
|
||||
sidebarCollapsed: false,
|
||||
sidebarWidth: 240
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// 共享常量 — 主进程和渲染进程共用
|
||||
export const APP_VERSION = 'v0.4.5'
|
||||
export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
|
||||
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
|
||||
export const SKIP_DIRS = new Set([
|
||||
'node_modules', '.git', '.svn', '.hg', 'dist', 'out',
|
||||
'.next', '.nuxt', '__pycache__', '.DS_Store'
|
||||
])
|
||||
// 共享常量 — 主进程和渲染进程共用
|
||||
export const APP_VERSION = 'v0.5.0'
|
||||
export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
|
||||
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
|
||||
export const SKIP_DIRS = new Set([
|
||||
'node_modules', '.git', '.svn', '.hg', 'dist', 'out',
|
||||
'.next', '.nuxt', '__pycache__', '.DS_Store'
|
||||
])
|
||||
|
||||
+26
-26
@@ -1,26 +1,26 @@
|
||||
// IPC 通道名常量 — 主进程和渲染进程共享
|
||||
export const IPC_CHANNELS = {
|
||||
// 渲染进程 → 主进程 (invoke)
|
||||
DIALOG_OPEN_FILE: 'dialog:openFile',
|
||||
FILE_READ: 'file:read',
|
||||
FILE_SAVE: 'file:save',
|
||||
FILE_SAVE_AS: 'file:saveAs',
|
||||
FILE_GET_CURRENT_PATH: 'file:getCurrentPath',
|
||||
FILE_STATS: 'file:stats',
|
||||
FILE_RELOAD: 'file:reload',
|
||||
TAB_SWITCHED: 'tab:switched',
|
||||
WINDOW_FORCE_CLOSE: 'window:forceClose',
|
||||
WINDOW_CANCEL_CLOSE: 'window:cancelClose',
|
||||
DIR_READ_TREE: 'dir:readTree',
|
||||
DIR_OPEN_DIALOG: 'dir:openDialog',
|
||||
DIR_WATCH: 'dir:watch',
|
||||
DIR_UNWATCH: 'dir:unwatch',
|
||||
|
||||
// 主进程 → 渲染进程 (send)
|
||||
FILE_OPEN_IN_TAB: 'file:openInTab',
|
||||
FILE_EXTERNALLY_MODIFIED: 'file:externallyModified',
|
||||
WINDOW_CONFIRM_CLOSE: 'window:confirmClose',
|
||||
SIDEBAR_DIR_CHANGED: 'sidebar:dirChanged'
|
||||
} as const
|
||||
|
||||
export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]
|
||||
// IPC 通道名常量 — 主进程和渲染进程共享
|
||||
export const IPC_CHANNELS = {
|
||||
// 渲染进程 → 主进程 (invoke)
|
||||
DIALOG_OPEN_FILE: 'dialog:openFile',
|
||||
FILE_READ: 'file:read',
|
||||
FILE_SAVE: 'file:save',
|
||||
FILE_SAVE_AS: 'file:saveAs',
|
||||
FILE_GET_CURRENT_PATH: 'file:getCurrentPath',
|
||||
FILE_STATS: 'file:stats',
|
||||
FILE_RELOAD: 'file:reload',
|
||||
TAB_SWITCHED: 'tab:switched',
|
||||
WINDOW_FORCE_CLOSE: 'window:forceClose',
|
||||
WINDOW_CANCEL_CLOSE: 'window:cancelClose',
|
||||
DIR_READ_TREE: 'dir:readTree',
|
||||
DIR_OPEN_DIALOG: 'dir:openDialog',
|
||||
DIR_WATCH: 'dir:watch',
|
||||
DIR_UNWATCH: 'dir:unwatch',
|
||||
|
||||
// 主进程 → 渲染进程 (send)
|
||||
FILE_OPEN_IN_TAB: 'file:openInTab',
|
||||
FILE_EXTERNALLY_MODIFIED: 'file:externallyModified',
|
||||
WINDOW_CONFIRM_CLOSE: 'window:confirmClose',
|
||||
SIDEBAR_DIR_CHANGED: 'sidebar:dirChanged'
|
||||
} as const
|
||||
|
||||
export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]
|
||||
|
||||
Reference in New Issue
Block a user