Initial commit

This commit is contained in:
2026-03-08 01:34:54 +08:00
commit 1f104f73c8
441 changed files with 64911 additions and 0 deletions

25
src/App.tsx Normal file
View File

@@ -0,0 +1,25 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { NoteBrowser } from '@/pages/NoteBrowser'
import { SettingsSync } from '@/components/settings/SettingsSync'
import { ErrorBoundary } from '@/components/common/ErrorBoundary'
import '@/modules'
import { TimeTrackerProvider } from '@/modules/time-tracking'
function App() {
return (
<ErrorBoundary>
<BrowserRouter>
<TimeTrackerProvider>
<SettingsSync />
<Routes>
<Route path="/*" element={<NoteBrowser />} />
</Routes>
</TimeTrackerProvider>
</BrowserRouter>
</ErrorBoundary>
)
}
export default App

View File

@@ -0,0 +1,32 @@
import React from 'react'
import { Modal } from '../Modal'
import { DialogContent } from '../DialogContent'
interface ConfirmDialogProps {
isOpen: boolean
title: string
message: string
onConfirm: () => void
onCancel: () => void
}
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
title,
message,
onConfirm,
onCancel,
}) => {
return (
<Modal isOpen={isOpen} onClose={onCancel}>
<DialogContent
title={title}
onConfirm={onConfirm}
onCancel={onCancel}
confirmText="确定"
>
<p className="text-gray-600 dark:text-gray-300">{message}</p>
</DialogContent>
</Modal>
)
}

View File

@@ -0,0 +1 @@
export { ConfirmDialog } from './ConfirmDialog'

View File

@@ -0,0 +1,50 @@
import React, { useRef } from 'react'
import { createPortal } from 'react-dom'
import { useClickOutside } from '@/hooks/utils/useClickOutside'
export interface ContextMenuItem {
label: string
onClick: () => void
}
export interface ContextMenuProps {
items: ContextMenuItem[]
isOpen: boolean
position: { x: number; y: number }
onClose: () => void
}
export const ContextMenu: React.FC<ContextMenuProps> = ({ items, isOpen, position, onClose }) => {
const menuRef = useRef<HTMLDivElement>(null)
useClickOutside(menuRef, onClose, {
enabled: isOpen,
includeEscape: true
})
if (!isOpen || items.length === 0) {
return null
}
return createPortal(
<div
ref={menuRef}
className="fixed bg-white dark:bg-gray-900 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-[9999] min-w-[150px]"
style={{
left: `${position.x}px`,
top: `${position.y}px`,
}}
>
{items.map((item, index) => (
<div
key={index}
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer transition-colors"
onClick={item.onClick}
>
{item.label}
</div>
))}
</div>,
document.body
)
}

View File

@@ -0,0 +1,2 @@
export { ContextMenu } from './ContextMenu'
export type { ContextMenuItem, ContextMenuProps } from './ContextMenu'

View File

@@ -0,0 +1,78 @@
import React from 'react'
import { Loader2 } from 'lucide-react'
interface DialogContentProps {
title: string
children: React.ReactNode
footer?: React.ReactNode
onConfirm?: () => void
onCancel?: () => void
confirmText?: string
cancelText?: string
isConfirmDisabled?: boolean
isConfirmLoading?: boolean
confirmButtonVariant?: 'primary' | 'danger'
confirmButtonType?: 'button' | 'submit'
}
export const DialogContent = ({
title,
children,
footer,
onConfirm,
onCancel,
confirmText = '确认',
cancelText = '取消',
isConfirmDisabled = false,
isConfirmLoading = false,
confirmButtonVariant = 'primary',
confirmButtonType = 'button'
}: DialogContentProps) => {
const confirmButtonClass = confirmButtonVariant === 'danger'
? "px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
: "px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
const showConfirmButton = onConfirm || confirmButtonType === 'submit'
return (
<div className="p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
{title}
</h3>
<div className="mb-6">
{children}
</div>
{footer ? (
footer
) : (
<div className="flex justify-end gap-3">
{onCancel && (
<button
type="button"
onClick={onCancel}
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
>
{cancelText}
</button>
)}
{showConfirmButton && (
<button
type={confirmButtonType}
onClick={onConfirm}
disabled={isConfirmDisabled || isConfirmLoading}
className={confirmButtonClass}
>
{isConfirmLoading && <Loader2 className="animate-spin" size={16} />}
{confirmText}
</button>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1 @@
export { DialogContent } from './DialogContent'

View File

@@ -0,0 +1,93 @@
import React, { Component, ErrorInfo, ReactNode } from 'react'
import { AlertTriangle, RefreshCw } from 'lucide-react'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error: Error | null
errorInfo: ErrorInfo | null
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = {
hasError: false,
error: null,
errorInfo: null
}
}
static getDerivedStateFromError(error: Error): Partial<State> {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
this.setState({ errorInfo })
console.error('ErrorBoundary caught an error:', error, errorInfo)
}
handleRetry = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null
})
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback
}
return (
<div className="flex flex-col items-center justify-center min-h-[200px] p-6 bg-red-50 dark:bg-red-900/20 rounded-lg">
<AlertTriangle className="w-12 h-12 text-red-500 dark:text-red-400 mb-4" />
<h2 className="text-lg font-semibold text-red-700 dark:text-red-300 mb-2">
</h2>
<p className="text-sm text-red-600 dark:text-red-400 mb-4 text-center max-w-md">
{this.state.error?.message || '发生了未知错误'}
</p>
<button
onClick={this.handleRetry}
className="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
>
<RefreshCw size={16} />
</button>
{process.env.NODE_ENV === 'development' && this.state.errorInfo && (
<details className="mt-4 w-full max-w-lg">
<summary className="cursor-pointer text-sm text-red-500 dark:text-red-400">
</summary>
<pre className="mt-2 p-4 bg-red-100 dark:bg-red-900/40 rounded text-xs overflow-auto max-h-48 text-red-800 dark:text-red-200">
{this.state.error?.stack}
</pre>
</details>
)}
</div>
)
}
return this.props.children
}
}
export const withErrorBoundary = <P extends object>(
WrappedComponent: React.ComponentType<P>,
fallback?: ReactNode
) => {
return function WithErrorBoundaryWrapper(props: P) {
return (
<ErrorBoundary fallback={fallback}>
<WrappedComponent {...props} />
</ErrorBoundary>
)
}
}

View File

@@ -0,0 +1 @@
export { ErrorBoundary } from './ErrorBoundary'

View File

@@ -0,0 +1,56 @@
import React, { useEffect } from 'react'
import { createPortal } from 'react-dom'
interface ModalProps {
isOpen: boolean
onClose: () => void
children: React.ReactNode
className?: string
closeOnOverlayClick?: boolean
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
children,
className = 'max-w-md',
closeOnOverlayClick = false
}) => {
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose()
}
}
if (isOpen) {
document.addEventListener('keydown', handleEscape)
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = 'unset'
}
}, [isOpen, onClose])
if (!isOpen) return null
if (typeof document === 'undefined') return null
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in duration-200"
onClick={closeOnOverlayClick ? onClose : undefined}
>
<div
className={`bg-white dark:bg-gray-800 rounded-lg shadow-xl transform transition-all animate-in zoom-in-95 duration-200 w-full ${className}`}
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>,
document.body
)
}

View File

@@ -0,0 +1 @@
export { Modal } from './Modal'

View File

@@ -0,0 +1,96 @@
import React, { useState, useRef } from 'react'
import { ChevronDown, Check } from 'lucide-react'
import { useClickOutside } from '@/hooks/utils/useClickOutside'
export interface SelectOption {
value: string
label: string
icon?: React.ReactNode
}
interface SelectProps {
label?: string
value: string
options: SelectOption[]
onChange: (value: string) => void
className?: string
}
export const Select: React.FC<SelectProps> = ({
label,
value,
options,
onChange,
className = '',
}) => {
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const selectedOption = options.find((opt) => opt.value === value) || options[0]
useClickOutside(containerRef, () => setIsOpen(false), {
enabled: isOpen
})
return (
<div className={`relative ${className}`} ref={containerRef}>
{label && (
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{label}
</label>
)}
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-500 focus:border-transparent transition-all"
>
<span className="flex items-center gap-2 text-gray-900 dark:text-gray-100">
{selectedOption?.icon && <span className="text-gray-500 dark:text-gray-400">{selectedOption.icon}</span>}
<span>{selectedOption?.label}</span>
</span>
<ChevronDown
className={`w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${
isOpen ? 'transform rotate-180' : ''
}`}
/>
</button>
{isOpen && (
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-md shadow-lg max-h-60 overflow-auto animate-in fade-in zoom-in-95 duration-100">
<ul className="py-1">
{options.map((option) => (
<li
key={option.value}
onClick={() => {
onChange(option.value)
setIsOpen(false)
}}
className={`px-3 py-2 cursor-pointer flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors ${
value === option.value
? 'bg-gray-100 dark:bg-gray-600 text-gray-900 dark:text-gray-100'
: 'text-gray-700 dark:text-gray-200'
}`}
>
<div className="flex items-center gap-2">
{option.icon && (
<span
className={`${
value === option.value
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-400 dark:text-gray-500'
}`}
>
{option.icon}
</span>
)}
<span>{option.label}</span>
</div>
{value === option.value && <Check className="w-4 h-4" />}
</li>
))}
</ul>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,2 @@
export { Select } from './Select'
export type { SelectOption } from './Select'

View File

@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
import { ConfirmDialog } from '../ConfirmDialog'
describe('ConfirmDialog', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
cleanup()
vi.useRealTimers()
})
it('显示标题和消息', () => {
const mockOnConfirm = vi.fn()
const mockOnCancel = vi.fn()
render(
<ConfirmDialog
isOpen={true}
title="确认删除"
message="确定要删除这个项目吗?"
onConfirm={mockOnConfirm}
onCancel={mockOnCancel}
/>
)
expect(screen.getByText('确认删除')).toBeInTheDocument()
expect(screen.getByText('确定要删除这个项目吗?')).toBeInTheDocument()
})
it('点击确认按钮触发 onConfirm', () => {
const mockOnConfirm = vi.fn()
const mockOnCancel = vi.fn()
render(
<ConfirmDialog
isOpen={true}
title="确认删除"
message="确定要删除这个项目吗?"
onConfirm={mockOnConfirm}
onCancel={mockOnCancel}
/>
)
const confirmButton = screen.getByRole('button', { name: '确定' })
fireEvent.click(confirmButton)
expect(mockOnConfirm).toHaveBeenCalledTimes(1)
expect(mockOnCancel).not.toHaveBeenCalled()
})
it('点击取消按钮触发 onCancel', () => {
const mockOnConfirm = vi.fn()
const mockOnCancel = vi.fn()
render(
<ConfirmDialog
isOpen={true}
title="确认删除"
message="确定要删除这个项目吗?"
onConfirm={mockOnConfirm}
onCancel={mockOnCancel}
/>
)
const cancelButton = screen.getByRole('button', { name: '取消' })
fireEvent.click(cancelButton)
expect(mockOnCancel).toHaveBeenCalledTimes(1)
expect(mockOnConfirm).not.toHaveBeenCalled()
})
it('关闭时不渲染内容', () => {
const mockOnConfirm = vi.fn()
const mockOnCancel = vi.fn()
render(
<ConfirmDialog
isOpen={false}
title="确认删除"
message="确定要删除这个项目吗?"
onConfirm={mockOnConfirm}
onCancel={mockOnCancel}
/>
)
expect(screen.queryByText('确认删除')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
import { ErrorBoundary } from '../ErrorBoundary'
import React from 'react'
describe('ErrorBoundary', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
cleanup()
vi.useRealTimers()
})
it('正常渲染子组件', () => {
render(
<ErrorBoundary>
<div>Child Content</div>
</ErrorBoundary>
)
expect(screen.getByText('Child Content')).toBeInTheDocument()
})
it('捕获子组件错误并显示错误信息', () => {
const ThrowError: React.FC = () => {
throw new Error('Test error')
}
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
render(
<ErrorBoundary>
<ThrowError />
</ErrorBoundary>
)
vi.runAllTimers()
expect(screen.getByText('出现了一些问题')).toBeInTheDocument()
expect(screen.getByText('Test error')).toBeInTheDocument()
consoleErrorSpy.mockRestore()
})
it('错误边界不会捕获自身错误', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
render(
<ErrorBoundary>
<div>Normal Content</div>
</ErrorBoundary>
)
expect(screen.getByText('Normal Content')).toBeInTheDocument()
consoleErrorSpy.mockRestore()
})
it('点击重试按钮恢复', () => {
let shouldThrow = true
const ThrowError: React.FC = () => {
if (shouldThrow) {
throw new Error('Test error')
}
return <div>Recovered Content</div>
}
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { rerender } = render(
<ErrorBoundary>
<ThrowError />
</ErrorBoundary>
)
vi.runAllTimers()
expect(screen.getByText('出现了一些问题')).toBeInTheDocument()
shouldThrow = false
rerender(
<ErrorBoundary>
<ThrowError />
</ErrorBoundary>
)
vi.runAllTimers()
const retryButton = screen.getByRole('button', { name: /重试/i })
fireEvent.click(retryButton)
expect(screen.queryByText('出现了一些问题')).not.toBeInTheDocument()
consoleErrorSpy.mockRestore()
})
it('使用自定义 fallback', () => {
const ThrowError: React.FC = () => {
throw new Error('Test error')
}
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
render(
<ErrorBoundary fallback={<div>Custom Fallback</div>}>
<ThrowError />
</ErrorBoundary>
)
vi.runAllTimers()
expect(screen.getByText('Custom Fallback')).toBeInTheDocument()
expect(screen.queryByText('出现了一些问题')).not.toBeInTheDocument()
consoleErrorSpy.mockRestore()
})
})

View File

@@ -0,0 +1,85 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
import { Modal } from '../Modal'
describe('Modal', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
cleanup()
vi.useRealTimers()
})
it('打开时渲染内容', () => {
const mockOnClose = vi.fn()
render(
<Modal isOpen={true} onClose={mockOnClose}>
<div>Modal Content</div>
</Modal>
)
expect(screen.getByText('Modal Content')).toBeInTheDocument()
})
it('关闭时不渲染内容', () => {
const mockOnClose = vi.fn()
render(
<Modal isOpen={false} onClose={mockOnClose}>
<div>Modal Content</div>
</Modal>
)
expect(screen.queryByText('Modal Content')).not.toBeInTheDocument()
})
it('点击遮罩层关闭', () => {
const mockOnClose = vi.fn()
render(
<Modal isOpen={true} onClose={mockOnClose} closeOnOverlayClick={true}>
<div>Modal Content</div>
</Modal>
)
const overlay = screen.getByText('Modal Content').parentElement?.parentElement
if (overlay) {
fireEvent.click(overlay)
}
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
it('点击遮罩层不关闭 when closeOnOverlayClick is false', () => {
const mockOnClose = vi.fn()
render(
<Modal isOpen={true} onClose={mockOnClose} closeOnOverlayClick={false}>
<div>Modal Content</div>
</Modal>
)
const overlay = screen.getByText('Modal Content').parentElement?.parentElement
if (overlay) {
fireEvent.click(overlay)
}
expect(mockOnClose).not.toHaveBeenCalled()
})
it('显示关闭按钮并点击关闭', () => {
const mockOnClose = vi.fn()
render(
<Modal isOpen={true} onClose={mockOnClose}>
<div className="p-6">
<h3>Modal Title</h3>
<button onClick={mockOnClose} aria-label="关闭"></button>
</div>
</Modal>
)
const closeButton = screen.getByRole('button', { name: '关闭' })
fireEvent.click(closeButton)
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,114 @@
import React, { useEffect, useRef, useState } from 'react'
import { Modal } from '@/components/common/Modal'
import { Select } from '@/components/common/Select'
import { File, FileText, Code } from 'lucide-react'
import { DialogContent } from '@/components/common/DialogContent'
interface CreateItemDialogProps {
isOpen: boolean
title: string
placeholder: string
errorMessage?: string | null
initialValue?: string
onNameChange?: (name: string) => void
onSubmit: (name: string, initMethod: 'blank' | 'pdf' | 'html') => void
onCancel: () => void
showInitMethod?: boolean
}
export const CreateItemDialog: React.FC<CreateItemDialogProps> = ({
isOpen,
title,
placeholder,
errorMessage,
initialValue = '',
onNameChange,
onSubmit,
onCancel,
showInitMethod = false,
}) => {
const [name, setName] = useState('')
const [initMethod, setInitMethod] = useState<'blank' | 'pdf' | 'html'>('blank')
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (isOpen) {
setName(initialValue)
setInitMethod('blank')
onNameChange?.(initialValue)
setTimeout(() => {
inputRef.current?.focus()
if (initialValue) {
inputRef.current?.select()
}
}, 100)
}
}, [isOpen, initialValue])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (initMethod === 'pdf' || initMethod === 'html') {
onSubmit('', initMethod)
return
}
if (name.trim()) {
onSubmit(name.trim(), initMethod)
}
}
return (
<Modal isOpen={isOpen} onClose={onCancel}>
<form onSubmit={handleSubmit}>
<DialogContent
title={title}
onCancel={onCancel}
confirmText="确定"
confirmButtonType="submit"
>
{(!showInitMethod || initMethod === 'blank') && (
<div className="mb-4">
<input
ref={inputRef}
type="text"
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-gray-500 focus:border-transparent dark:bg-gray-700 dark:text-gray-100 ${
errorMessage ? 'border-red-400' : 'border-gray-300 dark:border-gray-600'
}`}
placeholder={placeholder}
value={name}
onChange={(e) => {
const next = e.target.value
setName(next)
onNameChange?.(next)
}}
autoComplete="off"
aria-invalid={Boolean(errorMessage)}
/>
{errorMessage ? <div className="mt-2 text-sm text-red-600">{errorMessage}</div> : null}
</div>
)}
{showInitMethod && (
<div className="mb-6">
<Select
label="初始化方式"
value={initMethod}
options={[
{ value: 'blank', label: '空白文档', icon: <File className="w-4 h-4" /> },
{ value: 'pdf', label: '基于 PDF 创建', icon: <FileText className="w-4 h-4" /> },
{ value: 'html', label: '基于 HTML 创建', icon: <Code className="w-4 h-4" /> },
]}
onChange={(val) => {
const newVal = val as 'blank' | 'pdf' | 'html'
setInitMethod(newVal)
if (newVal === 'blank') {
setTimeout(() => inputRef.current?.focus(), 50)
}
}}
/>
</div>
)}
</DialogContent>
</form>
</Modal>
)
}

View File

@@ -0,0 +1 @@
export { CreateItemDialog } from './CreateItemDialog'

View File

@@ -0,0 +1,477 @@
import React, { useRef, useState, useCallback } from 'react'
import { Modal } from '@/components/common/Modal'
import { DialogContent } from '@/components/common/DialogContent'
import { Upload, X, FileCode, FileText, Folder, ChevronDown, ChevronRight } from 'lucide-react'
interface FileTreeItem {
name: string
path: string
type: 'file' | 'folder'
children?: FileTreeItem[]
}
interface SelectedFile {
file: File
relativePath: string
}
interface CreatePyDemoDialogProps {
isOpen: boolean
onClose: () => void
onConfirm: (name: string, files: SelectedFile[]) => Promise<void>
year: number
month: number
}
const ALLOWED_EXTENSIONS = ['.py', '.md', '.txt', '.json', '.yaml', '.yml', '.toml', '.ini', '.cfg']
const getFilesFromEntry = async (
entry: FileSystemEntry,
includeRootFolder: boolean = false
): Promise<SelectedFile[]> => {
const files: SelectedFile[] = []
const traverse = async (currentEntry: FileSystemEntry, dirPath: string = ''): Promise<void> => {
if (currentEntry.isFile) {
const fileEntry = currentEntry as FileSystemFileEntry
const file = await new Promise<File>((resolve) => {
fileEntry.file(resolve)
})
const ext = '.' + file.name.split('.').pop()?.toLowerCase()
if (ALLOWED_EXTENSIONS.includes(ext)) {
const relativePath = dirPath ? `${dirPath}/${file.name}` : file.name
files.push({ file, relativePath })
}
} else if (currentEntry.isDirectory) {
const dirEntry = currentEntry as FileSystemDirectoryEntry
const reader = dirEntry.createReader()
const entries = await new Promise<FileSystemEntry[]>((resolve) => {
reader.readEntries(resolve)
})
for (const childEntry of entries) {
const childDirPath = dirPath ? `${dirPath}/${currentEntry.name}` : currentEntry.name
await traverse(childEntry, childDirPath)
}
}
}
if (entry.isDirectory && includeRootFolder) {
const dirEntry = entry as FileSystemDirectoryEntry
const reader = dirEntry.createReader()
const entries = await new Promise<FileSystemEntry[]>((resolve) => {
reader.readEntries(resolve)
})
for (const childEntry of entries) {
await traverse(childEntry, entry.name)
}
} else {
await traverse(entry)
}
return files
}
const processDroppedItems = async (items: DataTransferItemList): Promise<SelectedFile[]> => {
const allFiles: SelectedFile[] = []
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (item.kind === 'file') {
const entry = item.webkitGetAsEntry()
if (entry) {
const isSingleFolder = entry.isDirectory && items.length === 1
const files = await getFilesFromEntry(entry, isSingleFolder)
allFiles.push(...files)
}
}
}
return allFiles
}
const processInputFiles = (fileList: FileList, stripRootFolder: boolean = false): SelectedFile[] => {
const files: SelectedFile[] = []
for (let i = 0; i < fileList.length; i++) {
const file = fileList[i]
const ext = '.' + file.name.split('.').pop()?.toLowerCase()
if (ALLOWED_EXTENSIONS.includes(ext)) {
let relativePath = file.webkitRelativePath || file.name
if (stripRootFolder) {
const parts = relativePath.split('/')
if (parts.length > 1) {
relativePath = parts.slice(1).join('/')
}
}
files.push({ file, relativePath })
}
}
return files
}
const buildFileTree = (files: SelectedFile[]): FileTreeItem[] => {
const root: FileTreeItem[] = []
for (const { relativePath } of files) {
const parts = relativePath.replace(/\\/g, '/').split('/').filter(Boolean)
let current = root
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
const isFile = i === parts.length - 1
const fullPath = parts.slice(0, i + 1).join('/')
let existing = current.find(item => item.name === part)
if (!existing) {
existing = {
name: part,
path: fullPath,
type: isFile ? 'file' : 'folder',
children: isFile ? undefined : []
}
current.push(existing)
}
if (!isFile && existing.children) {
current = existing.children
}
}
}
const sortTree = (items: FileTreeItem[]): FileTreeItem[] => {
return items.sort((a, b) => {
if (a.type !== b.type) {
return a.type === 'folder' ? -1 : 1
}
return a.name.localeCompare(b.name)
}).map(item => ({
...item,
children: item.children ? sortTree(item.children) : undefined
}))
}
return sortTree(root)
}
const FileTreeItemComponent: React.FC<{
item: FileTreeItem
onRemove: (path: string) => void
depth?: number
}> = ({ item, onRemove, depth = 0 }) => {
const [expanded, setExpanded] = useState(true)
if (item.type === 'folder') {
return (
<div>
<div
className="flex items-center gap-1 py-1 px-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded cursor-pointer"
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronDown size={14} className="text-gray-400" />
) : (
<ChevronRight size={14} className="text-gray-400" />
)}
<Folder size={14} className="text-yellow-500" />
<span className="text-sm text-gray-700 dark:text-gray-300">{item.name}</span>
</div>
{expanded && item.children && (
<div>
{item.children.map(child => (
<FileTreeItemComponent
key={child.path}
item={child}
onRemove={onRemove}
depth={depth + 1}
/>
))}
</div>
)}
</div>
)
}
const ext = item.name.split('.').pop()?.toLowerCase()
const isPy = ext === 'py'
const isMd = ext === 'md'
return (
<div
className="flex items-center justify-between py-1 px-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded group"
style={{ paddingLeft: `${depth * 16 + 24}px` }}
>
<div className="flex items-center gap-2">
{isPy ? (
<FileCode size={14} className="text-blue-500" />
) : isMd ? (
<FileText size={14} className="text-purple-500" />
) : (
<FileText size={14} className="text-gray-400" />
)}
<span className="text-sm text-gray-700 dark:text-gray-300">{item.name}</span>
</div>
<button
onClick={(e) => {
e.stopPropagation()
onRemove(item.path)
}}
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-opacity"
>
<X size={12} className="text-gray-400" />
</button>
</div>
)
}
export const CreatePyDemoDialog: React.FC<CreatePyDemoDialogProps> = ({
isOpen,
onClose,
onConfirm,
year,
month
}) => {
const [name, setName] = useState('')
const [selectedFiles, setSelectedFiles] = useState<SelectedFile[]>([])
const [error, setError] = useState<string | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const folderInputRef = useRef<HTMLInputElement>(null)
const resetState = useCallback(() => {
setName('')
setSelectedFiles([])
setError(null)
setIsDragging(false)
setIsLoading(false)
}, [])
const handleClose = () => {
resetState()
onClose()
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
}
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
try {
const files = await processDroppedItems(e.dataTransfer.items)
if (files.length === 0) {
setError('未找到支持的文件类型')
return
}
setSelectedFiles(prev => [...prev, ...files])
setError(null)
} catch (err) {
console.error('Failed to process dropped files:', err)
setError('处理文件失败')
}
}
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const files = processInputFiles(e.target.files, false)
if (files.length === 0) {
setError('未找到支持的文件类型')
return
}
setSelectedFiles(prev => [...prev, ...files])
setError(null)
}
e.target.value = ''
}
const handleFolderSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const files = processInputFiles(e.target.files, true)
if (files.length === 0) {
setError('未找到支持的文件类型')
return
}
setSelectedFiles(prev => [...prev, ...files])
setError(null)
}
e.target.value = ''
}
const handleRemoveFile = (path: string) => {
const normalizedPath = path.replace(/\\/g, '/')
setSelectedFiles(prev =>
prev.filter(f => f.relativePath.replace(/\\/g, '/') !== normalizedPath)
)
}
const handleConfirm = async () => {
const trimmedName = name.trim()
if (!trimmedName) {
setError('请输入 Demo 名称')
return
}
if (!/^[a-zA-Z0-9_\-\u4e00-\u9fa5]+$/.test(trimmedName)) {
setError('名称只能包含字母、数字、下划线、中划线和中文')
return
}
setIsLoading(true)
try {
await onConfirm(trimmedName, selectedFiles)
handleClose()
} catch (err) {
console.error('Failed to create demo:', err)
setError('创建失败,可能已存在同名 Demo')
} finally {
setIsLoading(false)
}
}
const fileTree = buildFileTree(selectedFiles)
return (
<Modal isOpen={isOpen} onClose={handleClose} className="max-w-lg">
<DialogContent
title="新建 Python Demo"
onCancel={handleClose}
onConfirm={handleConfirm}
confirmText="创建"
isConfirmDisabled={!name.trim()}
isConfirmLoading={isLoading}
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Demo
</label>
<input
type="text"
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-gray-500 focus:border-transparent dark:bg-gray-700 dark:text-gray-100 ${
error && !name.trim() ? 'border-red-400' : 'border-gray-300 dark:border-gray-600'
}`}
placeholder="my-demo"
value={name}
onChange={(e) => {
setName(e.target.value)
setError(null)
}}
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
</label>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileSelect}
/>
<input
ref={folderInputRef}
type="file"
multiple
className="hidden"
onChange={handleFolderSelect}
/>
<div
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors cursor-pointer ${
isDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<Upload className="w-8 h-8 mx-auto mb-2 text-gray-400" />
<p className="text-sm text-gray-600 dark:text-gray-400">
</p>
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
.py, .md, .txt, .json, .yaml
</p>
</div>
<div className="flex gap-2 mt-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
if (folderInputRef.current) {
folderInputRef.current.setAttribute('webkitdirectory', '')
folderInputRef.current.click()
}
}}
className="flex items-center gap-1 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
>
<Folder size={14} />
</button>
</div>
</div>
{selectedFiles.length > 0 && (
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
({selectedFiles.length} )
</label>
<button
type="button"
onClick={() => setSelectedFiles([])}
className="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
</button>
</div>
<div className="max-h-48 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800/50">
{fileTree.map(item => (
<FileTreeItemComponent
key={item.path}
item={item}
onRemove={handleRemoveFile}
/>
))}
</div>
</div>
)}
{error && (
<div className="text-sm text-red-600 dark:text-red-400">
{error}
</div>
)}
<div className="text-sm text-gray-500 dark:text-gray-400">
{year}{month}
</div>
</div>
</DialogContent>
</Modal>
)
}

View File

@@ -0,0 +1 @@
export { CreatePyDemoDialog } from './CreatePyDemoDialog'

View File

@@ -0,0 +1,76 @@
import React, { useRef, useState, useEffect, useCallback } from 'react'
import { Modal } from '@/components/common/Modal'
import { DialogContent } from '@/components/common/DialogContent'
interface DeleteConfirmDialogProps {
isOpen: boolean
title: string
message: string
expectedText: string
confirmText?: string
buttonVariant?: 'primary' | 'danger'
onConfirm: () => void
onCancel: () => void
}
export const DeleteConfirmDialog: React.FC<DeleteConfirmDialogProps> = ({
isOpen,
title,
message,
expectedText,
confirmText = '删除',
buttonVariant = 'danger',
onConfirm,
onCancel,
}) => {
const [inputValue, setInputValue] = useState('')
const inputRef = useRef<HTMLInputElement | null>(null)
useEffect(() => {
if (!isOpen) return
setInputValue('')
const timeoutId = window.setTimeout(() => {
inputRef.current?.focus()
inputRef.current?.select()
}, 0)
return () => window.clearTimeout(timeoutId)
}, [isOpen])
const isMatch = inputValue === expectedText
const handleSubmit = useCallback((e: React.FormEvent) => {
e.preventDefault()
if (!isMatch) return
onConfirm()
}, [isMatch, onConfirm])
return (
<Modal isOpen={isOpen} onClose={onCancel}>
<form onSubmit={handleSubmit}>
<DialogContent
title={title}
onCancel={onCancel}
confirmButtonType="submit"
confirmButtonVariant={buttonVariant}
isConfirmDisabled={!isMatch}
confirmText={confirmText}
>
<p className="text-gray-600 dark:text-gray-300 mb-4">{message}</p>
<div className="mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<span className="font-mono font-bold text-red-600 dark:text-red-400">{expectedText}</span> :
</label>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
ref={inputRef}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-gray-500 focus:border-gray-500 sm:text-sm dark:bg-gray-700 dark:text-gray-100"
placeholder={expectedText}
/>
</div>
</DialogContent>
</form>
</Modal>
)
}

View File

@@ -0,0 +1 @@
export { DeleteConfirmDialog } from './DeleteConfirmDialog'

View File

@@ -0,0 +1,64 @@
import React from 'react'
import { Modal } from '@/components/common/Modal'
import { FileText } from 'lucide-react'
import { DialogContent } from '@/components/common/DialogContent'
interface ExportDialogProps {
isOpen: boolean
onClose: () => void
onExportMarkdown: () => void
onExportPDF: () => void
isExporting: boolean
}
export const ExportDialog: React.FC<ExportDialogProps> = ({
isOpen,
onClose,
onExportMarkdown,
onExportPDF,
isExporting
}) => {
return (
<Modal isOpen={isOpen} onClose={onClose} className="max-w-sm" closeOnOverlayClick={true}>
<DialogContent
title="导出文档"
onCancel={onClose}
cancelText="取消"
>
<div className="flex flex-col gap-3">
<button
onClick={onExportMarkdown}
disabled={isExporting}
className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors group disabled:opacity-50 disabled:cursor-not-allowed"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-gray-900 dark:text-gray-100">
<FileText size={20} />
</div>
<div className="text-left">
<div className="font-medium text-gray-900 dark:text-gray-100"> Markdown</div>
<div className="text-xs text-gray-500 dark:text-gray-400"> (.md + images)</div>
</div>
</div>
</button>
<button
onClick={onExportPDF}
disabled={isExporting}
className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors group disabled:opacity-50 disabled:cursor-not-allowed"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center text-red-600 dark:text-red-400">
<FileText size={20} />
</div>
<div className="text-left">
<div className="font-medium text-gray-900 dark:text-gray-100"> PDF</div>
<div className="text-xs text-gray-500 dark:text-gray-400"></div>
</div>
</div>
</button>
</div>
</DialogContent>
</Modal>
)
}

View File

@@ -0,0 +1 @@
export { ExportDialog } from './ExportDialog'

View File

@@ -0,0 +1,85 @@
import React, { useState } from 'react'
import { Modal } from '@/components/common/Modal'
import { DialogContent } from '@/components/common/DialogContent'
interface FileSelectDialogProps<T> {
isOpen: boolean
onClose: () => void
onConfirm: (selected: T) => void
title: string
description: string
selectButtonText: string
selectFile: () => Promise<T | null>
renderSelected: (selected: T) => React.ReactNode
errorMessage?: string | null
}
export const FileSelectDialog = <T,>({
isOpen,
onClose,
onConfirm,
title,
description,
selectButtonText,
selectFile,
renderSelected,
errorMessage
}: FileSelectDialogProps<T>) => {
const [selected, setSelected] = useState<T | null>(null)
const handleSelectFile = async () => {
const result = await selectFile()
if (result) {
setSelected(result)
}
}
const handleConfirm = () => {
if (selected) {
onConfirm(selected)
}
}
const handleClose = () => {
setSelected(null)
onClose()
}
return (
<Modal isOpen={isOpen} onClose={handleClose}>
<DialogContent
title={title}
onCancel={handleClose}
onConfirm={handleConfirm}
isConfirmDisabled={!selected}
confirmText="确定"
>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
{description}
</p>
<div className="mb-6">
<button
type="button"
onClick={handleSelectFile}
className="px-4 py-2 bg-gray-50 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-600 text-sm font-semibold transition-colors"
>
{selectButtonText}
</button>
{selected && (
<div className="mt-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
{renderSelected(selected)}
</div>
)}
{errorMessage && (
<div className="mt-2 text-sm text-red-600 dark:text-red-400">
{errorMessage}
</div>
)}
</div>
</DialogContent>
</Modal>
)
}

View File

@@ -0,0 +1 @@
export { FileSelectDialog } from './FileSelectDialog'

View File

@@ -0,0 +1,94 @@
import React, { useState } from 'react'
import { Modal } from '@/components/common/Modal'
import { DialogContent } from '@/components/common/DialogContent'
import type { LocalHtmlInfo } from '@/lib/api'
interface HTMLSelectDialogProps {
isOpen: boolean
onClose: () => void
onLocalHtmlSelect: (info: LocalHtmlInfo) => void
errorMessage?: string | null
}
export const HTMLSelectDialog: React.FC<HTMLSelectDialogProps> = ({
isOpen,
onClose,
onLocalHtmlSelect,
errorMessage
}) => {
const [selectedInfo, setSelectedInfo] = useState<LocalHtmlInfo | null>(null)
const handleSelectFile = async () => {
if (!window.electronAPI?.selectHtmlFile) return
const result = await window.electronAPI.selectHtmlFile()
if (result.success && result.htmlPath && result.htmlDir) {
setSelectedInfo({
htmlPath: result.htmlPath,
htmlDir: result.htmlDir,
assetsDirName: result.assetsDirName,
assetsFiles: result.assetsFiles,
})
}
}
const handleConfirm = () => {
if (selectedInfo) {
onLocalHtmlSelect(selectedInfo)
handleClose()
}
}
const handleClose = () => {
setSelectedInfo(null)
onClose()
}
const fileName = selectedInfo?.htmlPath?.replace(/\\/g, '/').split('/').pop() || ''
const assetsCount = selectedInfo?.assetsFiles?.length || 0
return (
<Modal isOpen={isOpen} onClose={handleClose}>
<DialogContent
title="选择 HTML 文件"
onCancel={handleClose}
onConfirm={handleConfirm}
isConfirmDisabled={!selectedInfo}
confirmText="确定"
>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
HTML xxx_files
</p>
<div className="mb-6">
<button
type="button"
onClick={handleSelectFile}
className="px-4 py-2 bg-gray-50 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-600 text-sm font-semibold transition-colors"
>
</button>
{selectedInfo && (
<div className="mt-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
<div className="text-sm text-gray-700 dark:text-gray-300">
<div className="font-medium mb-1">{fileName}</div>
{selectedInfo.assetsDirName && (
<div className="text-gray-500 dark:text-gray-400">
{assetsCount}
</div>
)}
</div>
</div>
)}
{errorMessage && (
<div className="mt-2 text-sm text-red-600 dark:text-red-400">
{errorMessage}
</div>
)}
</div>
</DialogContent>
</Modal>
)
}

View File

@@ -0,0 +1 @@
export { HTMLSelectDialog } from './HTMLSelectDialog'

View File

@@ -0,0 +1,95 @@
import React, { useRef, useState } from 'react'
import { Modal } from '@/components/common/Modal'
import { DialogContent } from '@/components/common/DialogContent'
interface PDFSelectDialogProps {
isOpen: boolean
onClose: () => void
onFileSelect: (file: File) => void
errorMessage?: string | null
}
export const PDFSelectDialog: React.FC<PDFSelectDialogProps> = ({
isOpen,
onClose,
onFileSelect,
errorMessage
}) => {
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleButtonClick = () => {
fileInputRef.current?.click()
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0]
if (file.type === 'application/pdf') {
setSelectedFile(file)
} else {
alert('请选择 PDF 文件')
e.target.value = ''
}
}
}
const handleConfirm = () => {
if (selectedFile) {
onFileSelect(selectedFile)
}
}
const handleClose = () => {
setSelectedFile(null)
onClose()
}
return (
<Modal isOpen={isOpen} onClose={handleClose}>
<DialogContent
title="选择 PDF 文件"
onCancel={handleClose}
onConfirm={handleConfirm}
isConfirmDisabled={!selectedFile}
confirmText="确定"
>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
PDF
</p>
<div className="mb-6">
<input
ref={fileInputRef}
type="file"
accept=".pdf"
className="hidden"
onChange={handleFileChange}
/>
<button
type="button"
onClick={handleButtonClick}
className="px-4 py-2 bg-gray-50 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-600 text-sm font-semibold transition-colors"
>
</button>
{selectedFile && (
<div className="mt-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
<div className="text-sm text-gray-700 dark:text-gray-300 font-medium">
{selectedFile.name}
</div>
</div>
)}
{errorMessage && (
<div className="mt-2 text-sm text-red-600 dark:text-red-400">
{errorMessage}
</div>
)}
</div>
</DialogContent>
</Modal>
)
}

View File

@@ -0,0 +1 @@
export { PDFSelectDialog } from './PDFSelectDialog'

View File

@@ -0,0 +1,212 @@
import { Milkdown, MilkdownProvider } from '@milkdown/react'
import { useState, useRef, useCallback, useEffect, type CSSProperties } from 'react'
import type { EditorView } from 'prosemirror-view'
import type { Node } from 'prosemirror-model'
import { useMarkdownLogic } from '@/hooks/domain/useMarkdownLogic'
import { useImageAutoDelete } from '@/hooks/domain/useImageAutoDelete'
import type { TOCItem } from '@/lib/utils'
import { useMarkdownDisplay } from '@/stores'
import { MathModal } from './MathModal'
import './styles.css'
interface MarkdownProps {
content: string
filePath: string
onChange?: (markdown: string) => void
readOnly?: boolean
onTocUpdated?: (toc: TOCItem[]) => void
}
interface MathModalState {
isOpen: boolean
value: string
nodePos: number | null
nodeTypeName: string | null
}
const MarkdownInner = ({ content, filePath, onChange, readOnly = false, onTocUpdated }: MarkdownProps) => {
const containerRef = useRef<HTMLDivElement | null>(null)
const currentViewRef = useRef<EditorView | null>(null)
const { fontSize, zoom, setZoom } = useMarkdownDisplay()
const wrapperStyle = {
'--markdown-font-size': `${fontSize}px`,
} as CSSProperties
useEffect(() => {
const container = containerRef.current
if (!container) return
const handleWheel = (e: WheelEvent) => {
if (e.ctrlKey) {
e.preventDefault()
const delta = e.deltaY > 0 ? -10 : 10
setZoom(zoom + delta)
}
}
container.addEventListener('wheel', handleWheel, { passive: false })
return () => {
container.removeEventListener('wheel', handleWheel)
}
}, [zoom, setZoom])
const [mathModalState, setMathModalState] = useState<MathModalState>({
isOpen: false,
value: '',
nodePos: null,
nodeTypeName: null
})
useImageAutoDelete({ content, readOnly, filePath })
const handleClickOn = useCallback((view: EditorView, _pos: number, node: Node, nodePos: number, event: MouseEvent) => {
if (readOnly) return false
const typeName = node.type.name
if (typeName === 'math_block' || typeName === 'math_inline') {
const value = typeName === 'math_block'
? (node.attrs.value as string)
: node.textContent
currentViewRef.current = view
setMathModalState({
isOpen: true,
value: value || '',
nodePos,
nodeTypeName: typeName
})
event.preventDefault()
return true
}
return false
}, [readOnly])
const handleMathBlockCreated = useCallback((view: EditorView, _node: Node, nodePos: number) => {
if (readOnly) return
currentViewRef.current = view
setMathModalState({
isOpen: true,
value: '',
nodePos,
nodeTypeName: 'math_block'
})
}, [readOnly])
const { loading } = useMarkdownLogic({
content,
filePath,
readOnly,
onChange,
handleClickOn,
onMathBlockCreated: handleMathBlockCreated,
onTocUpdated
})
const handleMathSave = useCallback((newValue: string) => {
const { nodePos, nodeTypeName } = mathModalState
if (!currentViewRef.current || nodePos == null || !nodeTypeName) {
setMathModalState(prev => ({ ...prev, isOpen: false }))
return
}
const view = currentViewRef.current
const { state } = view
const currentNode = state.doc.nodeAt(nodePos)
if (!currentNode || currentNode.type.name !== nodeTypeName) {
setMathModalState(prev => ({ ...prev, isOpen: false }))
return
}
const tr = state.tr
tr.delete(nodePos, nodePos + currentNode.nodeSize)
view.dispatch(tr)
setTimeout(() => {
const { state: newState } = view
const tr = newState.tr
let newNode
if (nodeTypeName === 'math_inline') {
newNode = newState.schema.nodes.math_inline.create(
null,
newState.schema.text(newValue)
)
} else {
newNode = newState.schema.nodes.math_block.create(
{ ...currentNode.attrs, value: newValue }
)
}
tr.insert(nodePos, newNode)
view.dispatch(tr)
view.focus()
}, 0)
setMathModalState(prev => ({ ...prev, isOpen: false }))
}, [mathModalState])
const handleMathClose = useCallback(() => {
setMathModalState(prev => ({ ...prev, isOpen: false }))
}, [])
const handleMathDelete = useCallback(() => {
const { nodePos } = mathModalState
if (!currentViewRef.current || nodePos == null) {
setMathModalState(prev => ({ ...prev, isOpen: false }))
return
}
const view = currentViewRef.current
const { state } = view
const currentNode = state.doc.nodeAt(nodePos)
if (!currentNode) {
setMathModalState(prev => ({ ...prev, isOpen: false }))
return
}
const tr = state.tr
tr.delete(nodePos, nodePos + currentNode.nodeSize)
view.dispatch(tr)
view.focus()
setMathModalState(prev => ({ ...prev, isOpen: false }))
}, [mathModalState])
return (
<div
ref={containerRef}
className={`milkdown-editor-wrapper h-full w-full ${readOnly ? 'milkdown-readonly' : ''}`}
style={wrapperStyle}
>
<Milkdown />
{loading && (
<div className="p-4 text-gray-400"></div>
)}
<MathModal
isOpen={mathModalState.isOpen}
initialValue={mathModalState.value}
onClose={handleMathClose}
onSave={handleMathSave}
onDelete={handleMathDelete}
/>
</div>
)
}
export const Markdown = ({ content, filePath, onChange, readOnly = false, onTocUpdated }: MarkdownProps) => {
return (
<MilkdownProvider>
<MarkdownInner
key={filePath}
content={content}
filePath={filePath}
onChange={onChange}
readOnly={readOnly}
onTocUpdated={onTocUpdated}
/>
</MilkdownProvider>
)
}

View File

@@ -0,0 +1,81 @@
import React, { useState, useEffect, useRef } from 'react'
import { Modal } from '@/components/common/Modal'
interface MathModalProps {
isOpen: boolean
initialValue: string
onClose: () => void
onSave: (value: string) => void
onDelete: () => void
}
export const MathModal = ({ isOpen, initialValue, onClose, onSave, onDelete }: MathModalProps) => {
const [value, setValue] = useState(initialValue)
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
useEffect(() => {
setValue(initialValue)
}, [initialValue])
useEffect(() => {
if (!isOpen) return
const rafId = requestAnimationFrame(() => {
const textarea = textareaRef.current
if (!textarea) return
try {
textarea.focus({ preventScroll: true })
} catch {
textarea.focus()
}
const end = textarea.value.length
textarea.setSelectionRange(end, end)
})
return () => {
cancelAnimationFrame(rafId)
}
}, [isOpen])
return (
<Modal
isOpen={isOpen}
onClose={onClose}
className="w-[600px] max-w-[90vw]"
>
<div className="border-b px-4 py-2 text-sm text-gray-700"> LaTeX </div>
<div className="p-4">
<textarea
ref={textareaRef}
className="w-full h-40 border rounded p-2 font-mono text-sm"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</div>
<div className="flex justify-between gap-2 px-4 pb-4">
<button
className="px-3 py-1.5 rounded bg-red-600 text-white hover:bg-red-700 text-sm"
onClick={onDelete}
>
</button>
<div className="flex gap-2">
<button
className="px-3 py-1.5 rounded bg-gray-100 text-gray-700 hover:bg-gray-200 text-sm"
onClick={onClose}
>
</button>
<button
className="px-3 py-1.5 rounded bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 hover:bg-black dark:hover:bg-white text-sm"
onClick={() => onSave(value)}
>
</button>
</div>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,199 @@
.milkdown-editor-wrapper {
height: 100%;
width: 100%;
}
.milkdown-editor-wrapper .milkdown {
height: 100%;
}
.milkdown-editor-wrapper .milkdown-editor {
min-height: 100%;
padding: 0 !important;
white-space: pre-wrap;
}
.milkdown-editor-wrapper .ProseMirror {
font-size: var(--markdown-font-size, 16px) !important;
white-space: pre-wrap;
word-wrap: break-word;
outline: none;
color: var(--app-editor-text-color);
}
.milkdown .ProseMirror {
--md-border-color: rgba(0, 0, 0, 0.25);
--md-blockquote-border: rgba(0, 0, 0, 0.28);
--md-th-bg: rgba(241, 245, 249, 0.25);
}
.dark .milkdown .ProseMirror {
--md-border-color: rgba(255, 255, 255, 0.18);
--md-blockquote-border: rgba(255, 255, 255, 0.22);
--md-th-bg: rgba(163, 163, 163, 0.12);
}
.milkdown .ProseMirror p:has(> img:not(.ProseMirror-separator)) {
margin: 1rem 0 !important;
display: flex;
justify-content: center;
align-items: center;
line-height: 1.5 !important;
text-align: center;
overflow: visible;
}
.milkdown .ProseMirror p > img:not(.ProseMirror-separator) {
border-radius: 0.5rem;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
max-width: 100% !important;
height: auto;
margin: 0 !important;
display: block;
flex-shrink: 0;
}
.milkdown img.ProseMirror-separator {
display: inline-block !important;
width: 1ch;
height: 1em;
opacity: 0;
pointer-events: none;
flex: 0 0 auto;
}
.milkdown .ProseMirror pre {
background-color: #1e1e1e !important;
border-radius: 0.5rem;
margin: 1.5rem 0;
padding: 0 !important;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.875em;
line-height: 1.5;
color: #d4d4d4 !important;
position: relative;
}
.milkdown .ProseMirror pre > code {
background-color: transparent !important;
padding: 2.5rem 1rem 1rem !important;
display: block;
overflow-x: auto;
}
.milkdown .ProseMirror pre::before {
content: attr(data-language);
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2rem;
display: flex;
align-items: center;
padding: 0 0.75rem;
background-color: #252526;
border-bottom: 1px solid #2f2f2f;
border-left: 3px solid #6b7280;
border-top-left-radius: 0.5rem;
border-top-right-radius: 0.5rem;
color: #9cdcfe;
font-size: 0.75em;
letter-spacing: 0.08em;
text-transform: uppercase;
box-sizing: border-box;
z-index: 1;
}
.milkdown .ProseMirror pre:not([data-language])::before {
content: 'text';
}
.milkdown .ProseMirror pre .code-block-action-button {
position: absolute;
top: 0.25rem;
right: 0.5rem;
height: 1.5rem;
width: 1.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 0.375rem;
background: transparent;
color: #cbd5e1;
font-size: 1.1em;
line-height: 1;
cursor: pointer;
z-index: 2;
}
.milkdown .ProseMirror pre .code-block-action-button:hover {
background-color: rgba(163, 163, 163, 0.18);
color: #e5e5e5;
}
.milkdown .ProseMirror pre .code-block-action-button:active {
background-color: rgba(163, 163, 163, 0.28);
}
.milkdown .prose {
max-width: none !important;
}
.milkdown h1 { font-size: 2.25em; font-weight: 800; margin-bottom: 0.8888889em; }
.milkdown h2 { font-size: 1.5em; font-weight: 700; margin-top: 2em; margin-bottom: 1em; }
.milkdown h3 { font-size: 1.25em; font-weight: 600; margin-top: 1.6em; margin-bottom: 0.6em; }
.milkdown p { margin-top: 1.25em; margin-bottom: 1.25em; line-height: 1.75; color: inherit; }
.milkdown .katex-display {
text-align: center;
display: block;
margin: 1em 0;
overflow-x: auto;
overflow-y: hidden;
max-width: 100%;
}
.milkdown div[data-type="math_block"] {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
text-align: center;
}
.milkdown .ProseMirror ul > li::marker {
color: currentColor;
}
.milkdown .ProseMirror ol > li::marker {
color: currentColor;
}
.milkdown .ProseMirror table {
border-collapse: collapse;
width: 100%;
margin: 1.5rem 0;
}
.milkdown .ProseMirror th,
.milkdown .ProseMirror td {
border: 1px solid var(--md-border-color);
padding: 0.5rem 0.75rem;
text-align: left;
}
.milkdown .ProseMirror th {
background-color: var(--md-th-bg);
font-weight: 600;
}
.milkdown .ProseMirror blockquote {
border-left: 4px solid var(--md-blockquote-border);
padding-left: 1rem;
margin: 1.5rem 0;
color: inherit;
font-style: italic;
}

View File

@@ -0,0 +1,71 @@
import React from 'react'
import type { TOCItem } from '@/lib/utils'
import { useWallpaper } from '@/stores'
export interface TOCProps {
isOpen: boolean
onClose: () => void
tocItems: TOCItem[]
width: number
onResizeStart: (e: React.MouseEvent) => void
onTOCItemClick: (id: string) => void
}
const renderTOCItem = (item: TOCItem, onTOCItemClick: (id: string) => void) => {
const indent = (item.level - 1) * 16
return (
<div key={item.id} className="mb-1">
<div
className="text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white cursor-pointer px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
style={{ marginLeft: `${indent}px` }}
onClick={() => onTOCItemClick(item.id)}
>
{item.text}
</div>
{item.children.length > 0 && (
<div className="mt-1">
{item.children.map(child => renderTOCItem(child, onTOCItemClick))}
</div>
)}
</div>
)
}
export const TOC: React.FC<TOCProps> = ({ isOpen, onClose, tocItems, width, onResizeStart, onTOCItemClick }) => {
const { opacity } = useWallpaper()
return (
<div
className={`absolute left-0 top-0 bottom-0 border-r border-gray-200 dark:border-gray-700/60 flex flex-col z-30 transition-transform duration-300 ease-in-out transform backdrop-blur-sm ${isOpen ? 'translate-x-0' : '-translate-x-full'}`}
style={{
width,
backgroundColor: `rgba(var(--app-toc-bg-rgb), ${opacity})`,
}}
>
<div className="h-12 flex items-center px-4 border-b border-gray-200 dark:border-gray-700/60 font-semibold text-gray-700 dark:text-gray-200 flex justify-between">
<span className="text-base"></span>
<button
onClick={onClose}
className="w-8 h-8 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center justify-center text-2xl"
>
×
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 no-scrollbar">
{tocItems.length > 0 ? (
<div>
{tocItems.map(item => renderTOCItem(item, onTOCItemClick))}
</div>
) : (
<div className="text-gray-600 dark:text-gray-200"></div>
)}
</div>
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize z-10"
onMouseDown={onResizeStart}
/>
</div>
)
}

View File

@@ -0,0 +1,2 @@
export { TOC } from './TOC'
export type { TOCProps } from './TOC'

View File

@@ -0,0 +1,24 @@
import React from 'react'
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
interface UnsavedChangesDialogProps {
isOpen: boolean
onConfirm: () => void
onCancel: () => void
}
export const UnsavedChangesDialog: React.FC<UnsavedChangesDialogProps> = ({
isOpen,
onConfirm,
onCancel
}) => {
return (
<ConfirmDialog
isOpen={isOpen}
title="未保存的更改"
message="当前文档有未保存的更改,是否放弃更改并跳转?"
onConfirm={onConfirm}
onCancel={onCancel}
/>
)
}

View File

@@ -0,0 +1 @@
export { UnsavedChangesDialog } from './UnsavedChangesHandler'

View File

@@ -0,0 +1,85 @@
import React, { ReactNode } from 'react'
import { useFileSystemController } from '@/hooks/domain/useFileSystemController'
import { FileSystemContext } from '@/contexts/FileSystemContext'
import { CreateItemDialog } from '@/components/dialogs/CreateItemDialog'
import { PDFSelectDialog } from '@/components/dialogs/PDFSelectDialog'
import { HTMLSelectDialog } from '@/components/dialogs/HTMLSelectDialog'
import { DeleteConfirmDialog } from '@/components/dialogs/DeleteConfirmDialog'
import type { FileItem } from '@/lib/api'
interface FileSystemManagerProps {
children: ReactNode
onRefresh: () => void
onFileSelect: (file: FileItem) => void
onPathRename: (oldPath: string, newPath: string) => void
onFileDelete?: (path: string, type: 'file' | 'dir') => void
onPathMove?: (oldPath: string, newPath: string) => void
}
export const FileSystemManager = ({
children,
onRefresh,
onFileSelect,
onPathRename,
onFileDelete,
onPathMove
}: FileSystemManagerProps) => {
const fileSystemController = useFileSystemController({
onRefresh,
onFileSelect,
onPathRename,
onFileDelete,
onPathMove
})
const deletePathLabel = fileSystemController.deletePath.replace(/^markdowns[\\/]/, '')
return (
<FileSystemContext.Provider value={fileSystemController}>
{children}
<CreateItemDialog
isOpen={fileSystemController.createDialogOpen}
title={
fileSystemController.dialogMode === 'rename'
? '重命名'
: (fileSystemController.createType === 'dir' ? '新建文件夹' : '新建文档')
}
placeholder={
fileSystemController.dialogMode === 'rename'
? '请输入新名称'
: (fileSystemController.createType === 'dir' ? '请输入文件夹名称' : '请输入文档名称')
}
initialValue={fileSystemController.dialogMode === 'rename' ? fileSystemController.renameInitialValue : ''}
errorMessage={fileSystemController.createError}
onNameChange={() => fileSystemController.setCreateError(null)}
onSubmit={fileSystemController.handleCreateSubmit}
onCancel={fileSystemController.closeCreateDialog}
showInitMethod={fileSystemController.createType === 'file' && fileSystemController.dialogMode === 'create'}
/>
<PDFSelectDialog
isOpen={fileSystemController.pdfDialogOpen}
onClose={fileSystemController.closePdfDialog}
onFileSelect={fileSystemController.handlePdfFileSelect}
errorMessage={fileSystemController.pdfError}
/>
<HTMLSelectDialog
isOpen={fileSystemController.htmlDialogOpen}
onClose={fileSystemController.closeHtmlDialog}
onLocalHtmlSelect={fileSystemController.handleLocalHtmlSelect}
errorMessage={fileSystemController.htmlError}
/>
<DeleteConfirmDialog
isOpen={fileSystemController.deleteDialogOpen}
title={fileSystemController.deleteType === 'file' ? '删除文件' : '删除文件夹'}
message={`确定要删除 "${deletePathLabel}" 吗?`}
expectedText={fileSystemController.deleteType === 'file' ? 'DELETE FILE' : 'DELETE FOLDER'}
onConfirm={fileSystemController.handleDeleteSubmit}
onCancel={fileSystemController.closeDeleteDialog}
/>
</FileSystemContext.Provider>
)
}

View File

@@ -0,0 +1 @@
export { FileSystemManager } from './FileSystemManager'

View File

@@ -0,0 +1,156 @@
import React, { useState, useCallback } from 'react'
import type { FileItem } from '@/lib/api'
import { ChevronRight, ChevronDown, Folder, FileText } from 'lucide-react'
import { clsx } from 'clsx'
import { useFileTree } from '@/hooks/domain/useFileTree'
import { useDropTarget, useDragSource } from '@/hooks/domain/useDragDrop'
import { getDisplayName } from '@/lib/utils'
interface FileTreeProps {
path?: string
level?: number
onSelect: (file: FileItem) => void
selectedPath?: string
refreshKey?: number
onContextMenu?: (event: React.MouseEvent, path: string, type: 'file' | 'dir' | 'empty') => void
}
const FileTreeInner = ({ path = '', level = 0, onSelect, selectedPath, refreshKey, onContextMenu }: FileTreeProps) => {
const { items, loading } = useFileTree(path, refreshKey)
const { isDragOver, canBeDropTarget, dragHandlers } = useDropTarget({ targetPath: path })
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
onContextMenu?.(e, path, 'empty')
}, [onContextMenu, path])
if (loading && items.length === 0) {
return null
}
return (
<div
className={clsx(
"select-none w-full",
level > 0 && "pl-4",
isDragOver && canBeDropTarget && "bg-blue-50 dark:bg-blue-900/20 rounded-md"
)}
onContextMenu={handleContextMenu}
{...dragHandlers}
style={{ minHeight: '100%' }}
>
<div className="space-y-1">
{items.map((item) => (
<FileTreeNode
key={item.path}
item={item}
level={level}
onSelect={onSelect}
selectedPath={selectedPath}
refreshKey={refreshKey}
onContextMenu={onContextMenu}
/>
))}
</div>
</div>
)
}
export const FileTree = (props: FileTreeProps) => {
return <FileTreeInner {...props} />
}
interface FileTreeNodeProps {
item: FileItem
level: number
onSelect: (file: FileItem) => void
selectedPath?: string
refreshKey?: number
onContextMenu?: (event: React.MouseEvent, path: string, type: 'file' | 'dir' | 'empty') => void
}
const FileTreeNode = React.memo(({ item, level, onSelect, selectedPath, refreshKey, onContextMenu }: FileTreeNodeProps) => {
const [expanded, setExpanded] = useState(false)
const [hasExpanded, setHasExpanded] = useState(false)
const isSelected = selectedPath === item.path
const isDir = item.type === 'dir'
const { isDragOver, canBeDropTarget, dragHandlers: dropHandlers } = useDropTarget({
targetPath: item.path,
canAcceptDrop: () => isDir
})
const { isDragging, dragHandlers: dragSourceHandlers } = useDragSource({
path: item.path,
type: isDir ? 'dir' : 'file'
})
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation()
if (isDir) {
setExpanded(!expanded)
if (!hasExpanded) setHasExpanded(true)
} else {
onSelect(item)
}
}
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
onContextMenu?.(e, item.path, isDir ? 'dir' : 'file')
}
return (
<div>
<div
className={clsx(
"flex items-center gap-2 py-1 px-2 rounded-md cursor-pointer transition-colors text-sm",
isSelected
? "bg-gray-100 text-gray-800 dark:bg-gray-700/60 dark:text-gray-200"
: "hover:bg-gray-100/[var(--sidebar-opacity,0.8)] dark:hover:bg-gray-700/[var(--sidebar-opacity,0.8)] text-gray-700 dark:text-gray-200",
isDragging && "opacity-50",
isDragOver && canBeDropTarget && "bg-blue-100 dark:bg-blue-900/30 ring-2 ring-blue-400"
)}
onClick={handleClick}
onContextMenu={handleContextMenu}
{...dragSourceHandlers}
{...dropHandlers}
>
<span className="text-gray-400 dark:text-gray-400 shrink-0">
{isDir ? (
expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />
) : (
<span className="w-4" />
)}
</span>
<span className={clsx(
"text-gray-500 dark:text-gray-400 shrink-0",
isDragOver && canBeDropTarget && "text-blue-500 dark:text-blue-400"
)}>
{isDir ? <Folder size={16} /> : <FileText size={16} />}
</span>
<span className="truncate">{getDisplayName(item.name)}</span>
</div>
{isDir && (hasExpanded || expanded) && (
<div className={expanded ? 'block' : 'hidden'}>
<FileTreeInner
path={item.path}
level={level + 1}
onSelect={onSelect}
selectedPath={selectedPath}
refreshKey={refreshKey}
onContextMenu={onContextMenu}
/>
</div>
)}
</div>
)
})
FileTreeNode.displayName = 'FileTreeNode'

View File

@@ -0,0 +1 @@
export { FileTree } from './FileTree'

View File

@@ -0,0 +1,234 @@
import { FileTree } from '@/components/file-system/FileTree'
import { List } from 'lucide-react'
import type { FileItem } from '@/lib/api'
import type { TOCItem } from '@/lib/utils'
import { useWallpaper } from '@/stores'
import { ContextMenu } from '@/components/common/ContextMenu'
import React, { useState } from 'react'
import { useFileSystemContext } from '@/contexts/FileSystemContext'
import { DragContextProvider } from '@/contexts/DragContext'
import { useDropTarget } from '@/hooks/domain/useDragDrop'
import { isMarkdownFile } from '@/lib/utils'
import { clsx } from 'clsx'
interface SidebarProps {
isOpen: boolean
width: number
refreshKey: number
selectedFile?: FileItem | null
onFileSelect: (file: FileItem) => void
onResizeStart: (e: React.MouseEvent) => void
onTOCClick: () => void
tocOpen: boolean
showTOC: boolean
tocItems: TOCItem[]
onTOCItemClick: (id: string) => void
onTOCClose: () => void
}
const renderTOCItem = (item: TOCItem, onTOCItemClick: (id: string) => void) => {
const indent = (item.level - 1) * 16
return (
<div key={item.id} className="mb-1">
<div
className="text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white cursor-pointer px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
style={{ marginLeft: `${indent}px` }}
onClick={() => onTOCItemClick(item.id)}
>
{item.text}
</div>
{item.children.length > 0 && (
<div className="mt-1">
{item.children.map(child => renderTOCItem(child, onTOCItemClick))}
</div>
)}
</div>
)
}
const SidebarContent = ({
isOpen,
width,
refreshKey,
selectedFile,
onFileSelect,
onResizeStart,
onTOCClick,
tocOpen,
showTOC,
tocItems,
onTOCItemClick,
onTOCClose,
}: SidebarProps) => {
const { opacity } = useWallpaper()
const { openCreateDirectoryDialog, openCreateFileDialog, openRenameDialog, openDeleteDialog } = useFileSystemContext()
const [contextMenuOpen, setContextMenuOpen] = useState(false)
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
const [currentPath, setCurrentPath] = useState('')
const [contextMenuType, setContextMenuType] = useState<'file' | 'dir' | 'empty'>('empty')
const [localRefreshKey] = useState(0)
const combinedRefreshKey = refreshKey + localRefreshKey
const { isDragOver, canBeDropTarget, dragHandlers } = useDropTarget({ targetPath: 'markdowns' })
const handleContextMenu = (e: React.MouseEvent, path: string, type: 'file' | 'dir' | 'empty') => {
e.preventDefault()
setContextMenuPosition({ x: e.clientX, y: e.clientY })
setCurrentPath(path)
setContextMenuType(type)
setContextMenuOpen(true)
}
const handleContextMenuClose = () => {
setContextMenuOpen(false)
}
const getContextMenuItems = () => {
const items = []
if (contextMenuType === 'file' || contextMenuType === 'dir') {
items.push({
label: '重命名',
onClick: () => {
setContextMenuOpen(false)
openRenameDialog(currentPath)
},
})
}
if (contextMenuType === 'dir' || contextMenuType === 'empty') {
items.push(
{
label: '新建文件夹',
onClick: () => {
setContextMenuOpen(false)
openCreateDirectoryDialog(currentPath)
},
},
{
label: '新建文档',
onClick: () => {
setContextMenuOpen(false)
openCreateFileDialog(currentPath)
},
}
)
}
if (contextMenuType === 'file' || contextMenuType === 'dir') {
items.push({
label: '删除',
onClick: () => {
setContextMenuOpen(false)
openDeleteDialog(currentPath, contextMenuType)
},
})
}
return items
}
return (
<div
className={`
backdrop-blur-sm border-r border-gray-200 dark:border-gray-700/60 transition-none flex flex-col shrink-0 relative z-20
${isOpen ? '' : 'hidden'}
`}
style={{
width: isOpen ? width : 0,
backgroundColor: `rgba(var(--app-sidebar-bg-rgb), ${opacity})`,
'--sidebar-opacity': opacity
} as React.CSSProperties}
>
<div
className={clsx(
"absolute left-0 right-0 top-0 bottom-0 border-r border-gray-200 dark:border-gray-700/60 flex flex-col z-30 transition-transform duration-300 ease-in-out transform backdrop-blur-sm",
tocOpen ? 'translate-x-0' : '-translate-x-full'
)}
style={{
width,
backgroundColor: `rgba(var(--app-toc-bg-rgb), ${opacity})`,
}}
>
<div className="h-12 flex items-center px-4 border-b border-gray-200 dark:border-gray-700/60 font-semibold text-gray-700 dark:text-gray-200 flex justify-between">
<span></span>
<button
onClick={onTOCClose}
className="w-8 h-8 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center justify-center text-2xl"
>
×
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 no-scrollbar">
{tocItems.length > 0 ? (
<div>
{tocItems.map(item => renderTOCItem(item, onTOCItemClick))}
</div>
) : (
<div className="text-gray-600 dark:text-gray-200"></div>
)}
</div>
</div>
<div className={clsx(
"h-12 flex items-center px-4 border-b border-gray-200 dark:border-gray-700/60 font-semibold text-gray-700 dark:text-gray-200 whitespace-nowrap overflow-hidden transition-opacity duration-300",
tocOpen ? 'opacity-0' : 'opacity-100'
)}>
<span className="flex-1"></span>
</div>
<div
className={`flex-1 overflow-y-auto transition-opacity duration-300 ease-in-out ${tocOpen ? 'opacity-0 pointer-events-none' : 'opacity-100'}`}
style={{ minHeight: '0' }}
>
<div
className={`p-2 w-full min-h-full ${isDragOver && canBeDropTarget ? 'bg-blue-50 dark:bg-blue-900/20 rounded-md' : ''}`}
onContextMenu={(e) => handleContextMenu(e, "markdowns", 'empty')}
{...dragHandlers}
style={{ minHeight: '100%' }}
>
<FileTree
refreshKey={combinedRefreshKey}
path="markdowns"
onSelect={onFileSelect}
selectedPath={selectedFile?.path}
onContextMenu={handleContextMenu}
/>
</div>
</div>
<ContextMenu
isOpen={contextMenuOpen}
position={contextMenuPosition}
items={getContextMenuItems()}
onClose={handleContextMenuClose}
/>
{selectedFile && isMarkdownFile(selectedFile.name) && !showTOC && (
<button
onClick={onTOCClick}
className="absolute right-0 top-1/2 transform -translate-y-1/2 w-5 h-10 bg-gray-200 dark:bg-gray-700 border-t-r border-b-r border-gray-300 dark:border-gray-600 rounded-r flex items-center justify-center hover:bg-gray-300/[var(--sidebar-opacity)] dark:hover:bg-gray-600/[var(--sidebar-opacity)] transition-all duration-300 ease-in-out z-50"
style={{ right: -20, opacity: tocOpen ? 0 : 1 }}
>
<List size={14} className="text-gray-600 dark:text-gray-200 transform scale-x-[-1]" />
</button>
)}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize z-10"
onMouseDown={onResizeStart}
/>
</div>
)
}
export const Sidebar = (props: SidebarProps) => {
const { moveItem } = useFileSystemContext()
return (
<DragContextProvider moveItem={moveItem}>
<SidebarContent {...props} />
</DragContextProvider>
)
}

View File

@@ -0,0 +1 @@
export { Sidebar } from './Sidebar'

View File

@@ -0,0 +1,21 @@
import React from 'react'
interface PythonIconProps {
size?: number
className?: string
}
export const PythonIcon: React.FC<PythonIconProps> = ({ size = 24, className = '' }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
className={className}
>
<path d="M9.585 3c-.786 0-1.423.636-1.423 1.423v2.847h4.262c.393 0 .712.32.712.712v1.423H7.452c-1.18 0-2.135.956-2.135 2.135v3.557c0 1.18.956 2.135 2.135 2.135h1.423v-2.847c0-.786.636-1.423 1.423-1.423h4.262c.786 0 1.423-.636 1.423-1.423V5.135c0-1.18-.956-2.135-2.135-2.135H9.585zm-.712 1.78a.71.71 0 1 1 0 1.422.71.71 0 0 1 0-1.423z" />
<path d="M14.415 21c.786 0 1.423-.636 1.423-1.423v-2.847h-4.262a.71.71 0 0 1-.712-.712v-1.423h5.684c1.18 0 2.135-.956 2.135-2.135V8.903c0-1.18-.956-2.135-2.135-2.135h-1.423v2.847c0 .786-.636 1.423-1.423 1.423H10.44a1.42 1.42 0 0 0-1.423 1.423v3.557c0 1.18.956 2.135 2.135 2.135h3.263zm.712-1.78a.71.71 0 1 1 0-1.422.71.71 0 0 1 0 1.423z" />
</svg>
)
}

View File

@@ -0,0 +1 @@
export { PythonIcon } from './PythonIcon'

View File

@@ -0,0 +1,94 @@
import { ChevronRight } from 'lucide-react'
import { useWallpaper } from '@/stores'
import React from 'react'
import { clsx } from 'clsx'
import { getAllModules } from '@/lib/module-registry'
interface ActivityBarProps {
onSettingsClick: () => void
onRecycleBinClick: () => void
sidebarOpen: boolean
onToggleSidebar: () => void
onModuleClick: (moduleId: string) => void
}
export const ActivityBar = ({
onSettingsClick,
onRecycleBinClick,
sidebarOpen,
onToggleSidebar,
onModuleClick,
}: ActivityBarProps) => {
const { opacity } = useWallpaper()
const modules = getAllModules()
const topModules = modules.filter(m => m.id !== 'settings' && m.id !== 'recycle-bin')
const recycleBinModule = modules.find(m => m.id === 'recycle-bin')
return (
<div
className="flex flex-col items-center border-r border-gray-200 dark:border-gray-700/60 shrink-0 backdrop-blur-sm"
style={{
width: '48px',
backgroundColor: `rgba(var(--app-sidebar-bg-rgb), ${opacity})`,
}}
>
<div className="h-12 flex items-center justify-center shrink-0">
<button
onClick={onToggleSidebar}
className="p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
title={sidebarOpen ? '隐藏文件夹' : '显示文件夹'}
>
<ChevronRight
size={24}
className={clsx(
"transition-transform duration-200",
sidebarOpen && "rotate-180"
)}
/>
</button>
</div>
<div className="flex-1 flex flex-col items-center pt-2">
{topModules.map((module) => {
const Icon = module.icon
return (
<button
key={module.id}
onClick={() => onModuleClick(module.id)}
className="p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 mb-1"
title={module.name}
>
<Icon size={24} />
</button>
)
})}
</div>
<div className="pb-2 flex flex-col items-center">
{recycleBinModule && (() => {
const Icon = recycleBinModule.icon
return (
<button
onClick={onRecycleBinClick}
className="p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 mb-1"
title={recycleBinModule.name}
>
<Icon size={24} />
</button>
)
})()}
<button
onClick={onSettingsClick}
className="p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
title="设置"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1 @@
export { ActivityBar } from './ActivityBar'

View File

@@ -0,0 +1,108 @@
import type { FileItem } from '@/lib/api'
import { Edit3, Save, X, ChevronDown, MoreHorizontal } from 'lucide-react'
import React, { useState, useRef } from 'react'
import { ExportDialog } from '@/components/dialogs/ExportDialog'
import { useExport } from '@/hooks/domain/useExport'
import { useClickOutside } from '@/hooks/utils/useClickOutside'
export interface AppHeaderProps {
selectedFile?: FileItem | null
isEditing: boolean
onSave: () => void
onToggleEdit: () => void
}
export const AppHeader = ({
selectedFile,
isEditing,
onSave,
onToggleEdit,
}: AppHeaderProps) => {
const [dropdownOpen, setDropdownOpen] = useState(false)
const [showExportDialog, setShowExportDialog] = useState(false)
const { exportMarkdown, exportPDF, isExporting } = useExport()
const dropdownRef = useRef<HTMLDivElement>(null)
const toggleDropdown = () => {
setDropdownOpen(!dropdownOpen)
}
useClickOutside(dropdownRef, () => setDropdownOpen(false), {
enabled: dropdownOpen
})
return (
<>
<ExportDialog
isOpen={showExportDialog}
onClose={() => setShowExportDialog(false)}
onExportMarkdown={() => {
if (selectedFile) {
exportMarkdown(selectedFile)
setShowExportDialog(false)
}
}}
onExportPDF={() => {
if (selectedFile) {
exportPDF(selectedFile)
setShowExportDialog(false)
}
}}
isExporting={isExporting}
/>
<div className="flex items-center gap-2">
{!isEditing && (
<div className="relative" ref={dropdownRef}>
<button
onClick={toggleDropdown}
className="flex items-center gap-1 px-3 py-1.5 hover:bg-gray-100/50 dark:hover:bg-gray-700/50 text-gray-600 dark:text-gray-200 rounded text-sm transition-colors backdrop-blur-sm"
title="操作"
>
<MoreHorizontal size={18} />
<ChevronDown size={14} className={`transition-transform duration-200 ${dropdownOpen ? 'rotate-180' : ''}`} />
</button>
{dropdownOpen && (
<div className="absolute right-0 top-full mt-1 w-40 bg-white dark:bg-gray-900 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-50 py-1 animate-fade-in">
<button
onClick={() => {
setDropdownOpen(false)
setShowExportDialog(true)
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
</button>
</div>
)}
</div>
)}
{isEditing ? (
<>
<button
onClick={onSave}
className="flex items-center gap-1 px-3 py-1.5 bg-gray-800 dark:bg-gray-700 text-white rounded hover:bg-gray-900 dark:hover:bg-gray-600 text-sm transition-colors shadow-sm"
>
<Save size={16} />
</button>
<button
onClick={onToggleEdit}
className="flex items-center gap-1 px-3 py-1.5 bg-gray-100/50 dark:bg-gray-700/50 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-200/50 dark:hover:bg-gray-600/50 text-sm transition-colors backdrop-blur-sm"
>
<X size={16} />
</button>
</>
) : (
<button
onClick={onToggleEdit}
className="flex items-center gap-1 px-3 py-1.5 hover:bg-gray-100/50 dark:hover:bg-gray-700/50 text-gray-600 dark:text-gray-200 rounded text-sm transition-colors backdrop-blur-sm"
title="编辑 (Ctrl+S 保存)"
>
<Edit3 size={18} />
</button>
)}
</div>
</>
)
}

View File

@@ -0,0 +1,2 @@
export { AppHeader } from './AppHeader'
export type { AppHeaderProps } from './AppHeader'

View File

@@ -0,0 +1,150 @@
import { FileItem } from '@/lib/api'
import { X } from 'lucide-react'
import { clsx } from 'clsx'
import { useWallpaper } from '@/stores'
import { useEffect, useRef, useState } from 'react'
import { ContextMenu } from '@/components/common/ContextMenu'
import { getModuleTabId } from '@/lib/module-registry'
const HOME_TAB_ID = getModuleTabId('home') ?? 'home-tab'
export interface TabBarProps {
openFiles: FileItem[]
activeFile: FileItem | null
onTabClick: (file: FileItem) => void
onTabClose: (file: FileItem, e: React.MouseEvent) => void
onCloseOther?: (file: FileItem) => void
onCloseAll?: () => void
className?: string
style?: React.CSSProperties
variant?: 'default' | 'titlebar'
}
export const TabBar = ({ openFiles, activeFile, onTabClick, onTabClose, onCloseOther, onCloseAll, className, style, variant = 'default' }: TabBarProps) => {
const scrollContainerRef = useRef<HTMLDivElement>(null)
const { opacity } = useWallpaper()
const [contextMenu, setContextMenu] = useState<{
isOpen: boolean
position: { x: number; y: number }
file: FileItem | null
}>({
isOpen: false,
position: { x: 0, y: 0 },
file: null
})
const handleContextMenu = (e: React.MouseEvent, file: FileItem) => {
e.preventDefault()
e.stopPropagation()
setContextMenu({
isOpen: true,
position: { x: e.clientX, y: e.clientY },
file
})
}
const handleCloseContextMenu = () => {
setContextMenu(prev => ({ ...prev, isOpen: false }))
}
const handleCloseOther = () => {
if (contextMenu.file && onCloseOther) {
onCloseOther(contextMenu.file)
}
handleCloseContextMenu()
}
const handleCloseAll = () => {
if (onCloseAll) {
onCloseAll()
}
handleCloseContextMenu()
}
const contextMenuItems = [
{ label: '关闭其他标签页', onClick: handleCloseOther },
{ label: '关闭所有标签页', onClick: handleCloseAll }
]
useEffect(() => {
if (activeFile && scrollContainerRef.current) {
const activeTab = scrollContainerRef.current.querySelector('[data-active="true"]')
if (activeTab) {
activeTab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
}
}
}, [activeFile?.path, openFiles.length])
if (openFiles.length === 0) return null
const isTitlebar = variant === 'titlebar'
return (
<div
ref={scrollContainerRef}
className={clsx(
isTitlebar
? "flex overflow-hidden"
: "flex backdrop-blur-sm border-b border-gray-200 dark:border-gray-700/60 overflow-hidden",
className
)}
style={{
backgroundColor: isTitlebar ? 'transparent' : `rgba(var(--app-tabbar-bg-rgb), ${opacity})`,
'--tab-opacity': opacity,
...style
} as React.CSSProperties}
>
{openFiles.map((file) => {
const isActive = activeFile?.path === file.path
const isOnlyHomeTab = openFiles.length === 1 && file.path === HOME_TAB_ID
return (
<div
key={file.path}
data-active={isActive}
onClick={() => onTabClick(file)}
onContextMenu={(e) => handleContextMenu(e, file)}
className={clsx(
"group relative flex items-center px-3 min-w-[80px] max-w-[240px] text-sm cursor-pointer border-r border-gray-200 dark:border-gray-700/60 select-none rounded-t-md flex-1 titlebar-no-drag",
isTitlebar ? "h-full" : "h-10",
isActive
? "backdrop-blur-md text-gray-800 dark:text-gray-200 font-medium border-t-2 border-t-gray-400 dark:border-t-gray-500"
: "text-gray-600 dark:text-gray-300 hover:bg-gray-100/[var(--tab-opacity)] dark:hover:bg-gray-700/[var(--tab-opacity)] border-t-2 border-t-transparent"
)}
style={{
backgroundColor: isActive
? `rgba(var(--app-tab-active-bg-rgb), ${opacity * 0.55})`
: `rgba(var(--app-tab-inactive-bg-rgb), ${opacity * 0.65})`,
}}
>
<span className={clsx(
"w-full overflow-hidden whitespace-nowrap",
!isOnlyHomeTab && "pr-4"
)} style={{
maskImage: 'linear-gradient(to right, black calc(100% - 20px), transparent 100%)',
WebkitMaskImage: 'linear-gradient(to right, black calc(100% - 20px), transparent 100%)'
}}>{file.name}</span>
{!isOnlyHomeTab && (
<button
onClick={(e) => onTabClose(file, e)}
className={clsx(
"absolute right-2 p-0.5 rounded-sm opacity-0 group-hover:opacity-100 transition-opacity bg-inherit",
isActive
? "opacity-100 hover:bg-gray-100/[var(--tab-opacity)] dark:hover:bg-gray-700/[var(--tab-opacity)] text-gray-500 dark:text-gray-300"
: "hover:bg-gray-200/[var(--tab-opacity)] dark:hover:bg-gray-600/[var(--tab-opacity)] text-gray-400 dark:text-gray-400"
)}
>
<X size={14} />
</button>
)}
</div>
)
})}
<ContextMenu
items={contextMenuItems}
isOpen={contextMenu.isOpen}
position={contextMenu.position}
onClose={handleCloseContextMenu}
/>
</div>
)
}

View File

@@ -0,0 +1,2 @@
export { TabBar } from './TabBar'
export type { TabBarProps } from './TabBar'

View File

@@ -0,0 +1,44 @@
import { TabBar } from '../TabBar'
import type { FileItem } from '@/lib/api'
export interface TitleBarProps {
openFiles: FileItem[]
activeFile: FileItem | null
onTabClick: (file: FileItem) => void
onTabClose: (file: FileItem, e: React.MouseEvent) => void
onCloseOther?: (file: FileItem) => void
onCloseAll?: () => void
opacity: number
}
export const TitleBar = ({
openFiles,
activeFile,
onTabClick,
onTabClose,
onCloseOther,
onCloseAll,
opacity
}: TitleBarProps) => (
<div
className="titlebar-drag-region shrink-0 flex items-stretch pr-[138px] backdrop-blur-md"
style={{
backgroundColor: `rgba(var(--app-tabbar-bg-rgb), ${opacity})`,
paddingLeft: 0,
}}
>
<div className="flex-1 min-w-0 h-full overflow-hidden">
<TabBar
openFiles={openFiles}
activeFile={activeFile}
onTabClick={onTabClick}
onTabClose={onTabClose}
onCloseOther={onCloseOther}
onCloseAll={onCloseAll}
variant="titlebar"
className="h-full border-b-0"
style={{ backgroundColor: 'transparent' }}
/>
</div>
</div>
)

View File

@@ -0,0 +1,2 @@
export { TitleBar } from './TitleBar'
export type { TitleBarProps } from './TitleBar'

View File

@@ -0,0 +1,51 @@
import React, { useEffect, useRef, useState } from 'react'
import { useTheme, useWallpaper, useMarkdownDisplay } from '@/stores'
import { getSettings, saveSettings } from '@/lib/api'
export const SettingsSync: React.FC = () => {
const { theme, setTheme } = useTheme()
const { opacity, setOpacity } = useWallpaper()
const { fontSize, setFontSize } = useMarkdownDisplay()
const [isLoaded, setIsLoaded] = useState(false)
const timerRef = useRef<NodeJS.Timeout>()
useEffect(() => {
const loadSettings = async () => {
try {
const settings = await getSettings()
if (settings.theme) setTheme(settings.theme)
if (settings.wallpaperOpacity !== undefined) setOpacity(settings.wallpaperOpacity)
if (settings.markdownFontSize !== undefined) setFontSize(settings.markdownFontSize)
} catch (error) {
console.error('Failed to load settings:', error)
} finally {
setIsLoaded(true)
}
}
loadSettings()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (!isLoaded) return
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
saveSettings({
theme,
wallpaperOpacity: opacity,
markdownFontSize: fontSize,
}).catch(err => console.error('Failed to save settings:', err))
}, 1000)
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
}
}, [theme, opacity, fontSize, isLoaded])
return null
}

View File

@@ -0,0 +1 @@
export { SettingsSync } from './SettingsSync'

View File

@@ -0,0 +1,52 @@
import React from 'react'
import { Markdown } from '@/components/editor/Markdown/Markdown'
import type { FileItem } from '@/lib/api'
import type { TOCItem } from '@/lib/utils'
import { useMarkdownDisplay, useTabStore } from '@/stores'
interface MarkdownTabPageProps {
file: FileItem
onTocUpdated?: (toc: TOCItem[]) => void
}
export const MarkdownTabPage: React.FC<MarkdownTabPageProps> = ({
file,
onTocUpdated
}) => {
const { zoom } = useMarkdownDisplay()
const tab = useTabStore(state => state.tabs.get(file.path))
const content = tab?.content || ''
const unsavedContent = tab?.unsavedContent || ''
const isEditing = tab?.isEditing || false
const loading = tab?.loading || false
const displayContent = isEditing ? unsavedContent : content
const handleChange = (newContent: string) => {
useTabStore.getState().setUnsavedContent(file.path, newContent)
}
return (
<div
className="max-w-4xl mx-auto w-full"
style={{ zoom: zoom / 100 }}
>
<div className="h-full w-full">
{loading ? (
<div className="p-4 text-gray-400">...</div>
) : (
<Markdown
key={file.path}
content={displayContent}
filePath={file.path}
onChange={isEditing ? handleChange : undefined}
readOnly={!isEditing}
onTocUpdated={onTocUpdated}
/>
)}
{!isEditing && <div className="h-24" />}
</div>
</div>
)
}

View File

@@ -0,0 +1 @@
export { MarkdownTabPage } from './MarkdownTabPage'

View File

@@ -0,0 +1,80 @@
import React from 'react'
import type { FileItem } from '@/lib/api'
import type { TOCItem } from '@/lib/utils'
import { matchModule } from '@/lib/module-registry'
import { MarkdownTabPage } from '../MarkdownTabPage'
import { RemoteTabPage } from '@/modules/remote/RemoteTabPage'
interface TabContentCacheProps {
openFiles: FileItem[]
activeFile: FileItem | null
containerClassName?: string
onTocUpdated?: (filePath: string, toc: TOCItem[]) => void
}
export const TabContentCache: React.FC<TabContentCacheProps> = ({
openFiles,
activeFile,
containerClassName = '',
onTocUpdated
}) => {
const handleTocUpdate = (filePath: string) => (toc: TOCItem[]) => {
if (activeFile?.path === filePath) {
onTocUpdated?.(filePath, toc)
}
}
const renderContent = (file: FileItem, isActive: boolean) => {
const module = matchModule(file)
if (module) {
const Component = module.component
return <Component />
}
// 检查是否是远程桌面标签页
if (file.path.startsWith('remote-desktop://') || file.path.startsWith('remote-git://')) {
const urlParams = new URLSearchParams(file.path.split('?')[1])
const url = urlParams.get('url') || 'https://www.baidu.com'
const deviceName = urlParams.get('device') || ''
return <RemoteTabPage url={url} title={file.name} deviceName={deviceName} />
}
return <MarkdownTabPage file={file} onTocUpdated={handleTocUpdate(file.path)} />
}
return (
<div className="h-full w-full overflow-hidden">
{openFiles.map((file) => {
const isActive = activeFile?.path === file.path
return (
<div
key={file.path}
data-tab-scroll-container={file.path}
className={`h-full w-full overflow-y-auto ${containerClassName}`}
style={{ display: isActive ? 'block' : 'none' }}
aria-hidden={!isActive}
>
{renderContent(file, isActive)}
</div>
)
})}
</div>
)
}
export const scrollToElementInTab = (elementId: string, filePath: string) => {
const container = document.querySelector(`[data-tab-scroll-container="${filePath}"]`)
const element = document.getElementById(elementId)
if (container && element) {
const containerRect = container.getBoundingClientRect()
const elementRect = element.getBoundingClientRect()
const scrollTop = container.scrollTop + (elementRect.top - containerRect.top) - 20
container.scrollTo({
top: scrollTop,
behavior: 'smooth'
})
}
}

View File

@@ -0,0 +1 @@
export { TabContentCache, scrollToElementInTab } from './TabContentCache'

View File

@@ -0,0 +1,2 @@
export { TabContentCache, scrollToElementInTab } from './TabContentCache/TabContentCache'
export { MarkdownTabPage } from './MarkdownTabPage/MarkdownTabPage'

View File

@@ -0,0 +1,59 @@
import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
export interface DragState {
draggedPath: string | null
draggedType: 'file' | 'dir' | null
dropTargetPath: string | null
}
interface DragContextValue {
state: DragState
setState: (state: DragState) => void
moveItem: (sourcePath: string, targetDir: string) => Promise<void>
isDraggedInsidePath: (checkPath: string) => boolean
}
const DragContext = createContext<DragContextValue>({
state: { draggedPath: null, draggedType: null, dropTargetPath: null },
setState: () => { },
moveItem: async () => { },
isDraggedInsidePath: () => false
})
export const useDragContext = () => useContext(DragContext)
export const isDraggedInsidePath = (dragState: DragState, checkPath: string): boolean => {
if (!dragState.draggedPath) return false
if (dragState.draggedType === 'dir') {
return checkPath.startsWith(`${dragState.draggedPath}/`)
}
return false
}
interface DragContextProviderProps {
children: ReactNode
moveItem: (sourcePath: string, targetDir: string) => Promise<void>
}
export const DragContextProvider = ({ children, moveItem }: DragContextProviderProps) => {
const [dragState, setDragState] = useState<DragState>({
draggedPath: null,
draggedType: null,
dropTargetPath: null
})
const handleIsDraggedInsidePath = useCallback((checkPath: string) => {
return isDraggedInsidePath(dragState, checkPath)
}, [dragState])
return (
<DragContext.Provider value={{
state: dragState,
setState: setDragState,
moveItem,
isDraggedInsidePath: handleIsDraggedInsidePath
}}>
{children}
</DragContext.Provider>
)
}

View File

@@ -0,0 +1,19 @@
import { createContext, useContext } from 'react'
interface FileSystemContextType {
openCreateDirectoryDialog: (path: string) => void
openCreateFileDialog: (path: string) => void
openRenameDialog: (path: string) => void
openDeleteDialog: (path: string, type: 'file' | 'dir') => void
moveItem: (sourcePath: string, targetDir: string) => Promise<void>
}
export const FileSystemContext = createContext<FileSystemContextType | null>(null)
export const useFileSystemContext = () => {
const context = useContext(FileSystemContext)
if (!context) {
throw new Error('useFileSystemContext must be used within a FileSystemManager')
}
return context
}

View File

@@ -0,0 +1,256 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useClickOutside } from '../useClickOutside'
import { useRef, useState } from 'react'
describe('useClickOutside', () => {
let addEventListenerSpy: ReturnType<typeof vi.spyOn>
let removeEventListenerSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
addEventListenerSpy = vi.spyOn(document, 'addEventListener')
removeEventListenerSpy = vi.spyOn(document, 'removeEventListener')
})
afterEach(() => {
addEventListenerSpy.mockRestore()
removeEventListenerSpy.mockRestore()
})
describe('外部点击触发回调', () => {
it('当点击发生在 ref 元素外部时,应触发回调', () => {
const callback = vi.fn()
const ref = { current: document.createElement('div') }
document.body.appendChild(ref.current)
renderHook(() => useClickOutside(ref as any, callback))
const event = new MouseEvent('mousedown', { bubbles: true })
Object.defineProperty(event, 'target', { value: document.body })
document.dispatchEvent(event)
expect(callback).toHaveBeenCalledTimes(1)
document.body.removeChild(ref.current)
})
it('当点击发生在 ref 元素的子元素外部时,应触发回调', () => {
const callback = vi.fn()
const container = document.createElement('div')
const child = document.createElement('span')
container.appendChild(child)
document.body.appendChild(container)
const ref = { current: container }
renderHook(() => useClickOutside(ref as any, callback))
const event = new MouseEvent('mousedown', { bubbles: true })
Object.defineProperty(event, 'target', { value: document.body })
document.dispatchEvent(event)
expect(callback).toHaveBeenCalledTimes(1)
document.body.removeChild(container)
})
})
describe('内部点击不触发回调', () => {
it('当点击发生在 ref 元素内部时,不应触发回调', () => {
const callback = vi.fn()
const container = document.createElement('div')
const child = document.createElement('button')
container.appendChild(child)
document.body.appendChild(container)
const ref = { current: container }
renderHook(() => useClickOutside(ref as any, callback))
const event = new MouseEvent('mousedown', { bubbles: true })
Object.defineProperty(event, 'target', { value: child })
document.dispatchEvent(event)
expect(callback).not.toHaveBeenCalled()
document.body.removeChild(container)
})
it('当点击发生在 ref 元素本身时,不应触发回调', () => {
const callback = vi.fn()
const container = document.createElement('div')
document.body.appendChild(container)
const ref = { current: container }
renderHook(() => useClickOutside(ref as any, callback))
const event = new MouseEvent('mousedown', { bubbles: true })
Object.defineProperty(event, 'target', { value: container })
document.dispatchEvent(event)
expect(callback).not.toHaveBeenCalled()
document.body.removeChild(container)
})
})
describe('ref 为 null 时不崩溃', () => {
it('当 ref.current 为 null 时,不应崩溃', () => {
const callback = vi.fn()
const ref = { current: null }
expect(() => {
renderHook(() => useClickOutside(ref as any, callback))
}).not.toThrow()
})
it('当 ref.current 初始为 null 后变为元素时,应正常工作', () => {
const callback = vi.fn()
const { result } = renderHook(() => {
const ref = useRef<HTMLDivElement>(null)
const [, setHasElement] = useState(false)
useClickOutside(ref as any, callback)
return { ref, setHasElement }
})
act(() => {
result.current.ref.current = document.createElement('div')
result.current.setHasElement(true)
})
const event = new MouseEvent('mousedown', { bubbles: true })
Object.defineProperty(event, 'target', { value: document.body })
document.dispatchEvent(event)
expect(callback).toHaveBeenCalledTimes(1)
})
})
describe('组件卸载时清理事件监听', () => {
it('组件卸载时应移除事件监听', () => {
const callback = vi.fn()
const ref = { current: document.createElement('div') }
document.body.appendChild(ref.current)
const { unmount } = renderHook(() => useClickOutside(ref as any, callback))
expect(addEventListenerSpy).toHaveBeenCalledWith(
'mousedown',
expect.any(Function)
)
unmount()
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'mousedown',
expect.any(Function)
)
document.body.removeChild(ref.current)
})
it('多次挂载和卸载应正确管理事件监听', () => {
const callback = vi.fn()
const ref = { current: document.createElement('div') }
document.body.appendChild(ref.current)
const { unmount, rerender } = renderHook(
() => useClickOutside(ref as any, callback)
)
rerender()
const { unmount: unmount2 } = renderHook(
() => useClickOutside(ref as any, callback)
)
unmount()
unmount2()
expect(removeEventListenerSpy).toHaveBeenCalledTimes(2)
document.body.removeChild(ref.current)
})
})
describe('options 配置', () => {
it('当 enabled 为 false 时,不应添加事件监听', () => {
const callback = vi.fn()
const ref = { current: document.createElement('div') }
renderHook(() =>
useClickOutside(ref as any, callback, { enabled: false })
)
expect(addEventListenerSpy).not.toHaveBeenCalled()
})
it('当 eventType 为 click 时,应使用 click 事件', () => {
const callback = vi.fn()
const ref = { current: document.createElement('div') }
renderHook(() =>
useClickOutside(ref as any, callback, { eventType: 'click' })
)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'click',
expect.any(Function)
)
})
it('当 includeEscape 为 true 时,应添加 keydown 监听', () => {
const callback = vi.fn()
const ref = { current: document.createElement('div') }
renderHook(() =>
useClickOutside(ref as any, callback, { includeEscape: true })
)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'mousedown',
expect.any(Function)
)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'keydown',
expect.any(Function)
)
})
it('按下 Escape 键时应触发回调', () => {
const callback = vi.fn()
const ref = { current: document.createElement('div') }
renderHook(() =>
useClickOutside(ref as any, callback, { includeEscape: true })
)
const event = new KeyboardEvent('keydown', { key: 'Escape' })
document.dispatchEvent(event)
expect(callback).toHaveBeenCalledTimes(1)
})
it('按下非 Escape 键时不应触发回调', () => {
const callback = vi.fn()
const ref = { current: document.createElement('div') }
renderHook(() =>
useClickOutside(ref as any, callback, { includeEscape: true })
)
const event = new KeyboardEvent('keydown', { key: 'Enter' })
document.dispatchEvent(event)
expect(callback).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,316 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { useLocalStorageState } from '../useLocalStorageState'
describe('useLocalStorageState', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.getItem.mockReturnValue(null)
localStorage.setItem.mockClear()
localStorage.removeItem.mockClear()
})
describe('初始化测试', () => {
it('当 localStorage 无值时,应使用默认值', () => {
localStorage.getItem.mockReturnValue(null)
const { result } = renderHook(() =>
useLocalStorageState('test-key', 'default-value')
)
expect(result.current[0]).toBe('default-value')
expect(localStorage.getItem).toHaveBeenCalledWith('test-key')
})
it('当 localStorage 有值时,应使用存储的值', () => {
localStorage.getItem.mockReturnValue('stored-value')
const { result } = renderHook(() =>
useLocalStorageState('test-key', 'default-value')
)
expect(result.current[0]).toBe('stored-value')
})
it('当 localStorage 有值但解析失败时,应使用默认值', () => {
localStorage.getItem.mockReturnValue('invalid-json')
const { result } = renderHook(() =>
useLocalStorageState<number>('test-key', 0, { deserialize: JSON.parse })
)
expect(result.current[0]).toBe(0)
})
it('当 validate 返回 false 时,应使用默认值', () => {
localStorage.getItem.mockReturnValue('invalid-value')
const { result } = renderHook(() =>
useLocalStorageState('test-key', 'default', {
validate: (value: unknown): value is string => {
return typeof value === 'string' && value.startsWith('valid-')
},
})
)
expect(result.current[0]).toBe('default')
})
it('当 validate 返回 true 时,应使用存储的值', () => {
localStorage.getItem.mockReturnValue('valid-value')
const { result } = renderHook(() =>
useLocalStorageState('test-key', 'default', {
validate: (value: unknown): value is string => {
return typeof value === 'string' && value.startsWith('valid-')
},
})
)
expect(result.current[0]).toBe('valid-value')
})
it('支持对象类型的默认值', () => {
localStorage.getItem.mockReturnValue(null)
const defaultObj = { name: 'test', count: 0 }
const { result } = renderHook(() =>
useLocalStorageState('test-key', defaultObj)
)
expect(result.current[0]).toEqual(defaultObj)
})
it('支持数组类型的默认值', () => {
localStorage.getItem.mockReturnValue(null)
const defaultArray = [1, 2, 3]
const { result } = renderHook(() =>
useLocalStorageState('test-key', defaultArray)
)
expect(result.current[0]).toEqual(defaultArray)
})
})
describe('更新测试', () => {
it('设置新值应更新状态并写入 localStorage', async () => {
localStorage.getItem.mockReturnValue(null)
const { result } = renderHook(() =>
useLocalStorageState('test-key', 'default')
)
act(() => {
result.current[1]('new-value')
})
expect(result.current[0]).toBe('new-value')
expect(localStorage.setItem).toHaveBeenCalledWith('test-key', 'new-value')
})
it('函数式更新应基于当前值计算新值', async () => {
localStorage.getItem.mockReturnValue(null)
const { result } = renderHook(() =>
useLocalStorageState('test-key', 0)
)
act(() => {
result.current[1]((prev) => prev + 1)
})
expect(result.current[0]).toBe(1)
act(() => {
result.current[1]((prev) => prev + 10)
})
expect(result.current[0]).toBe(11)
})
it('多次更新应触发多次 localStorage 写入', async () => {
localStorage.getItem.mockReturnValue(null)
localStorage.setItem.mockClear()
const { result } = renderHook(() =>
useLocalStorageState('test-key', 'initial')
)
act(() => {
result.current[1]('first')
})
act(() => {
result.current[1]('second')
})
act(() => {
result.current[1]('third')
})
expect(localStorage.setItem).toHaveBeenCalledTimes(4)
})
it('设置对象值应正确序列化', async () => {
localStorage.getItem.mockReturnValue(null)
const { result } = renderHook(() =>
useLocalStorageState('test-key', { name: 'default' }, { serialize: JSON.stringify, deserialize: JSON.parse })
)
act(() => {
result.current[1]({ name: 'updated', count: 5 })
})
expect(localStorage.setItem).toHaveBeenCalledWith(
'test-key',
'{"name":"updated","count":5}'
)
})
})
describe('序列化测试', () => {
it('使用自定义 serialize 函数', () => {
localStorage.getItem.mockReturnValue(null)
const customSerialize = (value: { id: number }) => `ID:${value.id}`
const { result } = renderHook(() =>
useLocalStorageState('test-key', { id: 0 }, { serialize: customSerialize })
)
act(() => {
result.current[1]({ id: 42 })
})
expect(localStorage.setItem).toHaveBeenCalledWith('test-key', 'ID:42')
})
it('使用自定义 deserialize 函数', () => {
localStorage.getItem.mockReturnValue('CUSTOM:123')
const customDeserialize = (value: string) => {
const [, id] = value.split(':')
return { id: Number(id) }
}
const { result } = renderHook(() =>
useLocalStorageState('test-key', { id: 0 }, { deserialize: customDeserialize })
)
expect(result.current[0]).toEqual({ id: 123 })
})
it('同时使用自定义 serialize 和 deserialize', () => {
localStorage.getItem.mockReturnValue('JSON|{"a":1}')
const serialize = (value: { a: number }) => `JSON|${JSON.stringify(value)}`
const deserialize = (value: string) => {
const [, json] = value.split('|')
return JSON.parse(json)
}
const { result } = renderHook(() =>
useLocalStorageState('test-key', { a: 0 }, { serialize, deserialize })
)
expect(result.current[0]).toEqual({ a: 1 })
act(() => {
result.current[1]({ a: 2 })
})
expect(localStorage.setItem).toHaveBeenCalledWith('test-key', 'JSON|{"a":2}')
})
it('默认使用 String 作为 serialize', () => {
localStorage.getItem.mockReturnValue(null)
const { result } = renderHook(() =>
useLocalStorageState('test-key', 123)
)
act(() => {
result.current[1](456)
})
expect(localStorage.setItem).toHaveBeenCalledWith('test-key', '456')
})
it('当 deserialize 抛出错误时使用默认值', () => {
localStorage.getItem.mockReturnValue('invalid')
const deserialize = () => {
throw new Error('Parse error')
}
const { result } = renderHook(() =>
useLocalStorageState('test-key', 'default', { deserialize })
)
expect(result.current[0]).toBe('default')
})
})
describe('错误处理', () => {
it('当 localStorage.getItem 抛出错误时使用默认值', () => {
localStorage.getItem.mockImplementation(() => {
throw new Error('Storage unavailable')
})
const { result } = renderHook(() =>
useLocalStorageState('test-key', 'default')
)
expect(result.current[0]).toBe('default')
})
it('当 localStorage.setItem 抛出错误时不应崩溃', async () => {
localStorage.getItem.mockReturnValue(null)
localStorage.setItem.mockImplementation(() => {
throw new Error('Storage full')
})
const { result } = renderHook(() =>
useLocalStorageState('test-key', 'default')
)
act(() => {
result.current[1]('new-value')
})
expect(result.current[0]).toBe('new-value')
})
it('当 localStorage 不可用时(未定义)应使用默认值', () => {
const originalLocalStorage = global.localStorage
vi.stubGlobal('localStorage', undefined)
const { result } = renderHook(() =>
useLocalStorageState('test-key', 'default')
)
expect(result.current[0]).toBe('default')
vi.stubGlobal('localStorage', originalLocalStorage)
})
it('当 localStorage.setItem 不可用时应优雅降级', async () => {
const originalSetItem = localStorage.setItem
localStorage.getItem.mockReturnValue(null)
;(localStorage as any).setItem = undefined
const { result } = renderHook(() =>
useLocalStorageState('test-key', 'default')
)
act(() => {
result.current[1]('new-value')
})
expect(result.current[0]).toBe('new-value')
localStorage.setItem = originalSetItem
})
})
})

22
src/hooks/domain/index.ts Normal file
View File

@@ -0,0 +1,22 @@
export { useDialogState, useErrorDialogState } from './useDialogState'
export { useNoteBrowser, type UseNoteBrowserReturn } from './useNoteBrowser'
export { useNoteContent } from './useNoteContent'
export { useAutoLoadTabContent } from './useAutoLoadTabContent'
export { useFileSystemController } from './useFileSystemController'
export { useFileTabs } from './useFileTabs'
export { useFileTree } from './useFileTree'
export { useFileAction } from './useFileAction'
export { useFileImport } from './useFileImport'
export { usePDFImport } from './usePDFImport'
export { useHTMLImport } from './useHTMLImport'
export { useMarkdownLogic } from './useMarkdownLogic'
export { useImageAutoDelete } from './useImageAutoDelete'
export { useDropTarget, useDragSource } from './useDragDrop'
export { useUnsavedChangesHandler } from './useUnsavedChangesHandler'
export { useExport } from './useExport'

View File

@@ -0,0 +1,14 @@
import { useEffect } from 'react'
import { useTabStore, isSpecialTab } from '@/stores'
export const useAutoLoadTabContent = () => {
const tabs = useTabStore((state) => state.tabs)
useEffect(() => {
tabs.forEach((tab, path) => {
if (!isSpecialTab(tab.file) && !tab.loaded && !tab.loading) {
useTabStore.getState().loadContent(path)
}
})
}, [tabs])
}

View File

@@ -0,0 +1,45 @@
import { useState, useCallback } from 'react'
interface DialogState<T = void> {
isOpen: boolean
data: T | null
open: (data?: T) => void
close: () => void
}
export function useDialogState<T = void>(): DialogState<T> {
const [isOpen, setIsOpen] = useState(false)
const [data, setData] = useState<T | null>(null)
const open = useCallback((newData?: T) => {
if (newData !== undefined) {
setData(newData)
}
setIsOpen(true)
}, [])
const close = useCallback(() => {
setIsOpen(false)
setData(null)
}, [])
return { isOpen, data, open, close }
}
interface ErrorDialogState<T = void> extends DialogState<T> {
error: string | null
setError: (error: string | null) => void
}
export function useErrorDialogState<T = void>(): ErrorDialogState<T> {
const dialog = useDialogState<T>()
const [error, setError] = useState<string | null>(null)
const originalClose = dialog.close
const close = useCallback(() => {
originalClose()
setError(null)
}, [originalClose])
return { ...dialog, close, error, setError }
}

View File

@@ -0,0 +1,162 @@
import { useState, useRef, useCallback } from 'react'
import { useDragContext } from '@/contexts/DragContext'
interface UseDropTargetOptions {
targetPath: string
canAcceptDrop?: () => boolean
onDrop?: (sourcePath: string) => Promise<void>
}
interface UseDropTargetResult {
isDragOver: boolean
canBeDropTarget: boolean
dragHandlers: {
onDragOver: (e: React.DragEvent) => void
onDragEnter: (e: React.DragEvent) => void
onDragLeave: (e: React.DragEvent) => void
onDrop: (e: React.DragEvent) => void
}
}
export function useDropTarget({
targetPath,
canAcceptDrop,
onDrop
}: UseDropTargetOptions): UseDropTargetResult {
const { state: dragState, setState: setDragState, moveItem, isDraggedInsidePath } = useDragContext()
const [isDragOver, setIsDragOver] = useState(false)
const dragCounterRef = useRef(0)
const checkCanAccept = useCallback(() => {
if (!dragState.draggedPath) return false
if (!targetPath) return false
if (dragState.draggedPath === targetPath) return false
if (isDraggedInsidePath(targetPath)) return false
if (canAcceptDrop && !canAcceptDrop()) return false
return true
}, [dragState.draggedPath, targetPath, isDraggedInsidePath, canAcceptDrop])
const handleDragOver = useCallback((e: React.DragEvent) => {
if (!checkCanAccept()) return
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
}, [checkCanAccept])
const handleDragEnter = useCallback((e: React.DragEvent) => {
if (!checkCanAccept()) return
e.preventDefault()
e.stopPropagation()
dragCounterRef.current++
if (dragCounterRef.current === 1) {
setIsDragOver(true)
setDragState({
draggedPath: dragState.draggedPath,
draggedType: dragState.draggedType,
dropTargetPath: targetPath
})
}
}, [checkCanAccept, dragState.draggedPath, dragState.draggedType, targetPath, setDragState])
const handleDragLeave = useCallback((e: React.DragEvent) => {
if (!dragState.draggedPath) return
e.preventDefault()
e.stopPropagation()
dragCounterRef.current--
if (dragCounterRef.current === 0) {
setIsDragOver(false)
setDragState({
draggedPath: dragState.draggedPath,
draggedType: dragState.draggedType,
dropTargetPath: null
})
}
}, [dragState.draggedPath, dragState.draggedType, setDragState])
const handleDrop = useCallback(async (e: React.DragEvent) => {
if (!checkCanAccept()) return
e.preventDefault()
e.stopPropagation()
setIsDragOver(false)
dragCounterRef.current = 0
const sourcePath = dragState.draggedPath
if (!sourcePath) return
setDragState({
draggedPath: null,
draggedType: null,
dropTargetPath: null
})
if (onDrop) {
await onDrop(sourcePath)
} else {
await moveItem(sourcePath, targetPath)
}
}, [checkCanAccept, dragState.draggedPath, setDragState, onDrop, moveItem, targetPath])
const canBeDropTarget = checkCanAccept()
return {
isDragOver,
canBeDropTarget,
dragHandlers: {
onDragOver: handleDragOver,
onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave,
onDrop: handleDrop
}
}
}
interface UseDragSourceOptions {
path: string
type: 'file' | 'dir'
}
interface UseDragSourceResult {
isDragging: boolean
dragHandlers: {
draggable: boolean
onDragStart: (e: React.DragEvent) => void
onDragEnd: () => void
}
}
export function useDragSource({ path, type }: UseDragSourceOptions): UseDragSourceResult {
const { state: dragState, setState: setDragState } = useDragContext()
const handleDragStart = useCallback((e: React.DragEvent) => {
e.stopPropagation()
setDragState({
draggedPath: path,
draggedType: type,
dropTargetPath: null
})
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', path)
}, [path, type, setDragState])
const handleDragEnd = useCallback(() => {
setDragState({
draggedPath: null,
draggedType: null,
dropTargetPath: null
})
}, [setDragState])
const isDragging = dragState.draggedPath === path
return {
isDragging,
dragHandlers: {
draggable: true,
onDragStart: handleDragStart,
onDragEnd: handleDragEnd
}
}
}

View File

@@ -0,0 +1,144 @@
import { useState } from 'react'
import { FileItem, fetchFileContent } from '@/lib/api'
import JSZip from 'jszip'
import { resolveImagePath, generatePrintHtml, getFileName, getDisplayName } from '@/lib/utils'
export const useExport = () => {
const [isExporting, setIsExporting] = useState(false)
const exportPDF = async (file: FileItem) => {
try {
setIsExporting(true)
// 检查是否在 Electron 环境中
if (!window.electronAPI) {
alert('Web 端暂不支持导出 PDF请使用客户端。')
return
}
// 获取编辑器内容并生成 HTML
const element = document.querySelector('.milkdown .ProseMirror')
if (!element) throw new Error('找不到编辑器内容')
const title = getDisplayName(file.name)
const htmlContent = await generatePrintHtml(element as HTMLElement, title)
// 调用 Electron 导出,传递 HTML 内容
const result = await window.electronAPI.exportPDF(title, htmlContent)
if (!result.success && !result.canceled) {
throw new Error(result.error || 'Unknown error')
}
} catch (error) {
console.error('Export PDF failed:', error)
alert('导出 PDF 失败,请重试')
} finally {
setIsExporting(false)
}
}
const exportMarkdown = async (file: FileItem) => {
try {
setIsExporting(true)
// 1. Fetch content
const { content } = await fetchFileContent(file.path)
let newContent = content
// 2. Prepare ZIP
const zip = new JSZip()
const notebookFolder = zip.folder('notebook')
if (!notebookFolder) throw new Error('Failed to create folder in zip')
const imagesFolder = notebookFolder.folder('images')
if (!imagesFolder) throw new Error('Failed to create images folder in zip')
// 3. Find all images
const imageRegex = /!\[.*?\]\((.*?)\)|<img.*?src=["'](.*?)["']/g
const matches = Array.from(content.matchAll(imageRegex))
const processedImages = new Map<string, string>() // originalUrl -> newFileName
for (const match of matches) {
const fullMatch = match[0]
const url = match[1] || match[2]
if (!url || processedImages.has(url)) continue
let blob: Blob | null = null
let filename = ''
try {
// Use the shared image resolution logic
const fetchUrl = resolveImagePath(url, file.path)
if (!fetchUrl) continue
const res = await fetch(fetchUrl)
if (!res.ok) {
console.warn(`Failed to fetch image: ${url} -> ${fetchUrl} (${res.status})`)
continue
}
blob = await res.blob()
filename = getFileName(url).split('?')[0] || `image-${Date.now()}.png`
if (blob) {
// 处理重复文件名
const ext = filename.split('.').pop()
const name = filename.substring(0, filename.lastIndexOf('.'))
const safeFilename = `${name}-${Date.now()}.${ext}`
// 添加到 ZIP
imagesFolder.file(safeFilename, blob)
processedImages.set(url, safeFilename)
}
} catch (err) {
console.error(`Failed to export image: ${url}`, err)
}
}
// 4. Replace paths in content
newContent = newContent.replace(imageRegex, (match, p1, p2) => {
const url = p1 || p2
if (processedImages.has(url)) {
const newFilename = processedImages.get(url)
const newPath = `./images/${newFilename}`
// 保留标签的其余部分
if (match.startsWith('![')) {
return match.replace(url, newPath)
} else {
return match.replace(url, newPath)
}
}
return match
})
// 5. Add markdown file to ZIP
notebookFolder.file(file.name, newContent)
// 6. Generate ZIP and trigger download
const contentBlob = await zip.generateAsync({ type: 'blob' })
const downloadUrl = URL.createObjectURL(contentBlob)
const link = document.createElement('a')
link.href = downloadUrl
link.download = `${getDisplayName(file.name)}_export.zip`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(downloadUrl)
} catch (error) {
console.error('Export failed:', error)
alert('导出失败,请重试')
} finally {
setIsExporting(false)
}
}
return {
isExporting,
exportMarkdown,
exportPDF
}
}

View File

@@ -0,0 +1,105 @@
import { useCallback } from 'react'
import { createDirectory, createFile, renameItem, deleteFile, fetchFileContent, HttpError } from '@/lib/api'
import { extractLocalImagePathsFromMarkdown, ensurePathDoesNotExist, getFileName, isMarkdownFile } from '@/lib/utils'
import { FileActionError } from '@/lib/errors'
function handleFileActionError(error: unknown, actionName: string): never {
if (error instanceof FileActionError) throw error
if (error instanceof HttpError) {
if (error.status === 409 && error.code === 'ALREADY_EXISTS') {
throw new FileActionError('当前目录已存在同名文件或文件夹!', 'ALREADY_EXISTS')
}
if (error.status === 403) {
throw new FileActionError(`${actionName}失败:文件正在被其他程序使用,请关闭后重试。`, 'PERMISSION_DENIED')
}
if (error.status === 404) {
throw new FileActionError(`${actionName}失败:文件或文件夹不存在。`, 'NOT_FOUND')
}
}
console.error(`Error ${actionName} item:`, error)
throw new FileActionError(`${actionName}失败,请稍后重试。`)
}
export function useFileAction() {
const createNode = useCallback(async (path: string, type: 'dir' | 'file') => {
try {
await ensurePathDoesNotExist(path)
if (type === 'dir') {
await createDirectory(path)
} else {
await createFile(path)
}
} catch (error) {
handleFileActionError(error, '创建')
}
}, [])
const renameNode = useCallback(async (oldPath: string, newPath: string) => {
if (newPath === oldPath) return
try {
await ensurePathDoesNotExist(newPath)
await renameItem(oldPath, newPath)
} catch (error) {
handleFileActionError(error, '重命名')
}
}, [])
const deleteNode = useCallback(async (path: string, type: 'file' | 'dir') => {
try {
if (type === 'file' && isMarkdownFile(path)) {
try {
const { content } = await fetchFileContent(path)
const imagePaths = extractLocalImagePathsFromMarkdown(content, path)
if (imagePaths.length > 0) {
const results = await Promise.allSettled(imagePaths.map((p) => deleteFile(p)))
const failedCount = results.filter((r) => r.status === 'rejected').length
if (failedCount > 0) {
console.warn(`部分图片删除失败:${failedCount} 个。`)
}
}
} catch (err) {
console.warn('读取文件内容失败,跳过图片清理步骤:', err)
}
}
await deleteFile(path)
} catch (error) {
handleFileActionError(error, '删除')
}
}, [])
const moveNode = useCallback(async (sourcePath: string, targetDir: string): Promise<string> => {
if (sourcePath === targetDir) return sourcePath
const itemName = getFileName(sourcePath)
const newPath = targetDir ? `${targetDir}/${itemName}` : itemName
if (newPath === sourcePath) return sourcePath
if (sourcePath.startsWith(`${newPath}/`)) {
throw new FileActionError('不能将文件夹移动到其子文件夹中!', 'INVALID_MOVE')
}
if (targetDir.startsWith(`${sourcePath}/`)) {
throw new FileActionError('不能将文件夹移动到其子文件夹中!', 'INVALID_MOVE')
}
try {
await ensurePathDoesNotExist(newPath)
await renameItem(sourcePath, newPath)
return newPath
} catch (error) {
handleFileActionError(error, '移动')
}
}, [])
return {
createNode,
renameNode,
deleteNode,
moveNode
}
}

View File

@@ -0,0 +1,71 @@
import { useCallback } from 'react'
import { createFile, saveFileContent } from '@/lib/api'
import { FileActionError } from '@/lib/errors'
import { ensurePathDoesNotExist } from '@/lib/utils'
interface ImportOptions {
file: File | null
currentPath: string
assetsFiles?: File[]
}
interface ImportResult {
name: string
path: string
type: 'file'
size: number
modified: string
}
type UploadFunction = (file: File | null, targetPath: string, assetsFiles?: File[]) => Promise<void>
type FileImportOptions = {
placeholderContent: string
uploadFunction: UploadFunction
getBaseName: (fileName: string) => string
}
export function useFileImport() {
const importFile = useCallback(async (
{ file, currentPath, assetsFiles }: ImportOptions,
{ placeholderContent, uploadFunction, getBaseName }: FileImportOptions
): Promise<ImportResult> => {
const fileNameFull = file?.name || 'document.html'
const baseName = getBaseName(fileNameFull)
const fileName = `${baseName}.md`
const targetPath = currentPath ? `${currentPath}/${fileName}` : fileName
try {
await ensurePathDoesNotExist(targetPath)
await createFile(targetPath)
await saveFileContent(targetPath, placeholderContent)
uploadFunction(file, targetPath, assetsFiles)
.then(() => {
console.log(`${fileNameFull} upload started successfully`)
})
.catch(err => {
console.error(`${fileNameFull} upload failed:`, err)
})
return {
name: fileName,
path: targetPath,
type: 'file' as const,
size: 0,
modified: new Date().toISOString()
}
} catch (error) {
if (error instanceof FileActionError) {
throw error
}
console.error(`Error creating ${fileNameFull}-based file:`, error)
throw new FileActionError('创建文件失败,请稍后重试。')
}
}, [])
return {
importFile
}
}

View File

@@ -0,0 +1,236 @@
import { useCallback } from 'react'
import { useFileAction } from './useFileAction'
import { usePDFImport } from './usePDFImport'
import { useHTMLImport } from './useHTMLImport'
import { useErrorDialogState } from './useDialogState'
import { type FileItem, type LocalHtmlInfo } from '@/lib/api'
import { getFileName, getDisplayName, isMarkdownFile } from '@/lib/utils'
import { getErrorMessage } from '@/lib/errors'
interface CreateDialogData {
path: string
mode: 'create' | 'rename'
type: 'dir' | 'file'
initialValue: string
}
interface DeleteDialogData {
path: string
type: 'file' | 'dir'
}
interface UseFileSystemControllerProps {
onRefresh: () => void
onFileSelect: (file: FileItem) => void
onPathRename: (oldPath: string, newPath: string) => void
onFileDelete?: (path: string, type: 'file' | 'dir') => void
onPathMove?: (oldPath: string, newPath: string) => void
}
export const useFileSystemController = ({
onRefresh,
onFileSelect,
onPathRename,
onFileDelete,
onPathMove
}: UseFileSystemControllerProps) => {
const createDialog = useErrorDialogState<CreateDialogData>()
const pdfDialog = useErrorDialogState<string>()
const htmlDialog = useErrorDialogState<string>()
const deleteDialog = useErrorDialogState<DeleteDialogData>()
const openCreateDirectoryDialog = useCallback((path: string) => {
createDialog.open({ path, mode: 'create', type: 'dir', initialValue: '' })
}, [createDialog])
const openCreateFileDialog = useCallback((path: string) => {
createDialog.open({ path, mode: 'create', type: 'file', initialValue: '' })
}, [createDialog])
const openRenameDialog = useCallback((path: string) => {
const name = getFileName(path)
const displayName = getDisplayName(name)
createDialog.open({ path, mode: 'rename', type: 'file', initialValue: displayName })
}, [createDialog])
const openPdfDialog = useCallback(() => {
createDialog.close()
pdfDialog.open(createDialog.data?.path || '')
}, [createDialog, pdfDialog])
const openHtmlDialog = useCallback(() => {
createDialog.close()
htmlDialog.open(createDialog.data?.path || '')
}, [createDialog, htmlDialog])
const openDeleteDialog = useCallback((path: string, type: 'file' | 'dir') => {
deleteDialog.open({ path, type })
}, [deleteDialog])
const { createNode, renameNode, deleteNode, moveNode } = useFileAction()
const { importPDF } = usePDFImport()
const { importLocalHTML } = useHTMLImport()
const moveItem = useCallback(async (sourcePath: string, targetDir: string) => {
try {
const newPath = await moveNode(sourcePath, targetDir)
if (onPathMove && newPath !== sourcePath) {
onPathMove(sourcePath, newPath)
}
onRefresh()
} catch (error) {
alert(`移动失败: ${getErrorMessage(error)}`)
}
}, [moveNode, onRefresh, onPathMove])
const handleDeleteSubmit = useCallback(async () => {
if (!deleteDialog.data) return
const { path: deletePath, type: deleteType } = deleteDialog.data
try {
await deleteNode(deletePath, deleteType)
onRefresh()
if (onFileDelete) {
onFileDelete(deletePath, deleteType)
}
deleteDialog.close()
} catch (error) {
alert(`删除失败: ${getErrorMessage(error)}`)
}
}, [deleteDialog, onRefresh, onFileDelete, deleteNode])
const handleCreateSubmit = useCallback(async (name: string, initMethod: 'blank' | 'pdf' | 'html' = 'blank') => {
if (!createDialog.data) return
const { path: currentPath, mode, type: createType } = createDialog.data
const trimmed = name.trim()
if (initMethod !== 'pdf' && initMethod !== 'html' && !trimmed) return
if (mode === 'rename') {
const oldPath = currentPath
const parentPath = oldPath.substring(0, oldPath.lastIndexOf('/'))
const isFile = isMarkdownFile(oldPath)
const newPath = parentPath
? `${parentPath}/${trimmed}${isFile ? '.md' : ''}`
: `${trimmed}${isFile ? '.md' : ''}`
if (newPath === oldPath) {
createDialog.close()
return
}
try {
await renameNode(oldPath, newPath)
onPathRename(oldPath, newPath)
onRefresh()
createDialog.close()
} catch (error) {
createDialog.setError(getErrorMessage(error))
}
return
}
const targetPath = createType === 'dir' ? `${currentPath}/${trimmed}` : `${currentPath}/${trimmed}.md`
if (createType === 'file') {
if (initMethod === 'pdf') {
openPdfDialog()
return
}
if (initMethod === 'html') {
openHtmlDialog()
return
}
}
try {
await createNode(targetPath, createType)
if (createType === 'file') {
const fileName = getFileName(targetPath) || trimmed
const newFileItem: FileItem = {
name: fileName,
path: targetPath,
type: 'file',
size: 0,
modified: new Date().toISOString()
}
onFileSelect(newFileItem)
}
onRefresh()
createDialog.close()
} catch (error) {
createDialog.setError(getErrorMessage(error))
}
}, [
createDialog,
createNode,
renameNode,
onRefresh,
onFileSelect,
onPathRename,
openPdfDialog,
openHtmlDialog
])
const handlePdfFileSelect = useCallback(async (file: File) => {
if (!pdfDialog.data) return
const currentPath = pdfDialog.data
try {
const newFileItem = await importPDF(file, currentPath)
onFileSelect(newFileItem)
onRefresh()
pdfDialog.close()
} catch (error) {
pdfDialog.setError(getErrorMessage(error))
}
}, [pdfDialog, importPDF, onFileSelect, onRefresh])
const handleLocalHtmlSelect = useCallback(async (info: LocalHtmlInfo) => {
if (!htmlDialog.data) return
const currentPath = htmlDialog.data
try {
const newFileItem = await importLocalHTML(info, currentPath)
onFileSelect(newFileItem)
onRefresh()
htmlDialog.close()
} catch (error) {
htmlDialog.setError(getErrorMessage(error))
}
}, [htmlDialog, importLocalHTML, onFileSelect, onRefresh])
return {
createDialogOpen: createDialog.isOpen,
dialogMode: createDialog.data?.mode || 'create',
createType: createDialog.data?.type || 'dir',
currentPath: createDialog.data?.path || '',
renameInitialValue: createDialog.data?.initialValue || '',
createError: createDialog.error,
pdfDialogOpen: pdfDialog.isOpen,
pdfError: pdfDialog.error,
htmlDialogOpen: htmlDialog.isOpen,
htmlError: htmlDialog.error,
deleteDialogOpen: deleteDialog.isOpen,
deletePath: deleteDialog.data?.path || '',
deleteType: deleteDialog.data?.type || 'file',
setCreateError: createDialog.setError,
setPdfError: pdfDialog.setError,
setHtmlError: htmlDialog.setError,
openCreateDirectoryDialog,
openCreateFileDialog,
openRenameDialog,
openPdfDialog,
openHtmlDialog,
closeCreateDialog: createDialog.close,
closePdfDialog: pdfDialog.close,
closeHtmlDialog: htmlDialog.close,
openDeleteDialog,
closeDeleteDialog: deleteDialog.close,
handleCreateSubmit,
handlePdfFileSelect,
handleLocalHtmlSelect,
handleDeleteSubmit,
moveItem
}
}

View File

@@ -0,0 +1,177 @@
import { useCallback, useState } from 'react'
import type { FileItem } from '@/lib/api'
import { getFileName } from '@/lib/utils'
import { getModule } from '@/lib/module-registry'
interface UseFileTabsReturn {
selectedFile: FileItem | null
openFiles: FileItem[]
setSelectedFile: React.Dispatch<React.SetStateAction<FileItem | null>>
selectFile: (file: FileItem) => void
closeFile: (file: FileItem) => boolean
isFileOpen: (filePath: string) => boolean
getOpenFileCount: () => number
closeAllFiles: () => void
closeOtherFiles: (fileToKeep: FileItem) => void
renamePath: (oldPath: string, newPath: string) => void
handleDeletePath: (path: string, type: 'file' | 'dir') => void
openHomeTab: () => void
}
const getHomeFileItem = (): FileItem => {
const homeModule = getModule('home')
return homeModule?.fileItem ?? {
name: '首页',
path: 'home-tab',
type: 'file',
size: 0,
modified: new Date().toISOString(),
}
}
const getHomeTabId = (): string => {
const homeModule = getModule('home')
return homeModule?.tabId ?? 'home-tab'
}
export const useFileTabs = (): UseFileTabsReturn => {
const [selectedFile, setSelectedFile] = useState<FileItem | null>(() => getHomeFileItem())
const [openFiles, setOpenFiles] = useState<FileItem[]>(() => [getHomeFileItem()])
const openHomeTab = useCallback(() => {
const homeFileItem = getHomeFileItem()
const homeTabId = getHomeTabId()
setSelectedFile(homeFileItem)
setOpenFiles((prev) => {
if (prev.some((f) => f.path === homeTabId)) {
return prev
}
return [homeFileItem]
})
}, [])
const selectFile = useCallback((file: FileItem) => {
setSelectedFile(file)
setOpenFiles((prev) => {
if (prev.some((f) => f.path === file.path)) {
return prev
}
return [...prev, file]
})
}, [])
const closeFile = useCallback((file: FileItem): boolean => {
const homeFileItem = getHomeFileItem()
setOpenFiles((prev) => {
const index = prev.findIndex((f) => f.path === file.path)
const nextOpenFiles = prev.filter((f) => f.path !== file.path)
if (selectedFile?.path === file.path) {
setSelectedFile((currentSelected) => {
if (currentSelected?.path !== file.path) return currentSelected
if (nextOpenFiles.length === 0) {
return homeFileItem
}
return nextOpenFiles[Math.max(0, index - 1)] ?? nextOpenFiles[0] ?? homeFileItem
})
}
if (nextOpenFiles.length === 0) {
return [homeFileItem]
}
return nextOpenFiles
})
return false
}, [selectedFile])
const renamePath = useCallback((oldPath: string, newPath: string) => {
const updateFileItem = (file: FileItem): FileItem => {
if (file.path === oldPath) {
return {
...file,
path: newPath,
name: getFileName(newPath) || file.name
}
}
if (file.path.startsWith(oldPath + '/')) {
return {
...file,
path: newPath + file.path.slice(oldPath.length)
}
}
return file
}
setOpenFiles((prev) => prev.map(updateFileItem))
setSelectedFile((prev) => prev ? updateFileItem(prev) : null)
}, [])
const handleDeletePath = useCallback((path: string, type: 'file' | 'dir') => {
const homeFileItem = getHomeFileItem()
setOpenFiles((prev) => {
const isAffected = (filePath: string) => {
if (type === 'file') return filePath === path
return filePath === path || filePath.startsWith(path + '/')
}
const nextOpenFiles = prev.filter((f) => !isAffected(f.path))
if (selectedFile && isAffected(selectedFile.path)) {
if (nextOpenFiles.length === 0) {
setSelectedFile(homeFileItem)
return [homeFileItem]
} else {
const currentIndex = prev.findIndex(f => f.path === selectedFile.path)
const newIndex = Math.min(currentIndex, nextOpenFiles.length - 1)
setSelectedFile(nextOpenFiles[Math.max(0, newIndex)])
}
}
if (nextOpenFiles.length === 0) {
return [homeFileItem]
}
return nextOpenFiles
})
}, [selectedFile])
const isFileOpen = useCallback((filePath: string): boolean => {
return openFiles.some((file) => file.path === filePath)
}, [openFiles])
const getOpenFileCount = useCallback((): number => {
return openFiles.length
}, [openFiles])
const closeAllFiles = useCallback(() => {
const homeFileItem = getHomeFileItem()
setOpenFiles([homeFileItem])
setSelectedFile(homeFileItem)
}, [])
const closeOtherFiles = useCallback((fileToKeep: FileItem) => {
setOpenFiles([fileToKeep])
setSelectedFile(fileToKeep)
}, [])
return {
selectedFile,
openFiles,
setSelectedFile,
selectFile,
closeFile,
isFileOpen,
getOpenFileCount,
closeAllFiles,
closeOtherFiles,
renamePath,
handleDeletePath,
openHomeTab
}
}

View File

@@ -0,0 +1,49 @@
import { useState, useEffect } from 'react'
import { FileItem, fetchFiles, HttpError } from '@/lib/api'
import { isMarkdownFile } from '@/lib/utils'
export function useFileTree(path: string, refreshKey: number = 0) {
const [items, setItems] = useState<FileItem[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
let mounted = true
const load = async () => {
try {
setLoading(true)
const data = await fetchFiles(path)
if (mounted) {
const filtered = data.filter(item =>
item.type === 'dir' || isMarkdownFile(item.name)
)
setItems(filtered)
setError(null)
}
} catch (err) {
if (mounted) {
if (err instanceof HttpError && err.status === 404) {
setItems([])
setError(null)
} else {
console.error('Failed to load files', err)
setError(err instanceof Error ? err : new Error('Unknown error'))
}
}
} finally {
if (mounted) {
setLoading(false)
}
}
}
load()
return () => {
mounted = false
}
}, [path, refreshKey])
return { items, loading, error }
}

View File

@@ -0,0 +1,36 @@
import { useCallback } from 'react'
import { parseLocalHtml, type LocalHtmlInfo } from '@/lib/api'
import { useFileImport } from './useFileImport'
import { ASYNC_IMPORT_STATUS } from '@shared/constants'
export function useHTMLImport() {
const { importFile } = useFileImport()
const importLocalHTML = useCallback(async (info: LocalHtmlInfo, currentPath: string) => {
const htmlFileName = info.htmlPath.replace(/\\/g, '/').split('/').pop() || 'untitled.html'
let baseName = htmlFileName
if (htmlFileName.toLowerCase().endsWith('.html')) {
baseName = htmlFileName.slice(0, -5)
} else if (htmlFileName.toLowerCase().endsWith('.htm')) {
baseName = htmlFileName.slice(0, -4)
}
const fileName = `${baseName}.md`
const targetPath = currentPath ? `${currentPath}/${fileName}` : fileName
return importFile(
{ file: null, currentPath },
{
placeholderContent: ASYNC_IMPORT_STATUS.HTML_PARSING_CONTENT,
uploadFunction: async () => {
await parseLocalHtml(info, targetPath)
},
getBaseName: () => baseName
}
)
}, [importFile])
return {
importLocalHTML
}
}

View File

@@ -0,0 +1,57 @@
import { useEffect, useRef } from 'react'
import { deleteFile } from '@/lib/api'
import { extractLocalImagePathsFromMarkdown } from '@/lib/utils'
interface UseImageAutoDeleteProps {
content: string
readOnly: boolean
filePath: string
}
/**
* 自动清理 Markdown 内容中移除的本地图片
*
* 警告:此 Hook 会执行物理文件删除操作。
* 它通过比较 content 的变化,找出被移除的图片引用,并调用 API 删除对应的文件。
* 仅在 !readOnly 模式下工作,且在首次挂载时不会触发(防止误删)。
*/
export const useImageAutoDelete = ({ content, readOnly, filePath }: UseImageAutoDeleteProps) => {
const prevContentRef = useRef<string>(content)
const isMountedRef = useRef<boolean>(false)
useEffect(() => {
if (readOnly) return
// 首次挂载时不执行删除逻辑,避免切换文档时误删图片
if (!isMountedRef.current) {
isMountedRef.current = true
prevContentRef.current = content
return
}
const prevContent = prevContentRef.current
const newContent = content
// 提取之前内容中的图片路径
const prevImages = new Set(extractLocalImagePathsFromMarkdown(prevContent, filePath))
// 提取新内容中的图片路径
const newImages = new Set(extractLocalImagePathsFromMarkdown(newContent, filePath))
// 找出被删除的图片
const deletedImages = Array.from(prevImages).filter(imgPath => !newImages.has(imgPath))
// 删除被移除的图片文件
deletedImages.forEach(async (imgPath) => {
try {
await deleteFile(imgPath)
console.log(`Deleted image: ${imgPath}`)
} catch (error) {
console.error(`Failed to delete image ${imgPath}:`, error)
}
})
// 更新之前的内容
prevContentRef.current = newContent
}, [content, readOnly, filePath])
}

View File

@@ -0,0 +1,249 @@
import { useRef, useEffect } from 'react'
import { Editor, rootCtx, defaultValueCtx, remarkCtx, editorViewOptionsCtx, editorViewCtx, parserCtx, prosePluginsCtx } from '@milkdown/core'
import type { Ctx } from '@milkdown/ctx'
import { commonmark, headingIdGenerator } from '@milkdown/preset-commonmark'
import { gfm } from '@milkdown/preset-gfm'
import { listener, listenerCtx } from '@milkdown/plugin-listener'
import { history } from '@milkdown/plugin-history'
import { math } from '@milkdown/plugin-math'
import { block } from '@milkdown/plugin-block'
import { prism, prismConfig } from '@/lib/editor/milkdown/prism'
import remarkBreaks from 'remark-breaks'
import { useEditor } from '@milkdown/react'
import type { EditorView } from 'prosemirror-view'
import type { Node } from 'prosemirror-model'
import { Plugin, PluginKey } from 'prosemirror-state'
import { createClipboardImageUploaderPlugin } from '@/lib/editor/milkdown/clipboardProcessor'
import { registerCommonRefractorLanguages } from '@/lib/editor/milkdown/refractorLanguages'
import { codeBlockActionButtonRefreshMetaKey, createCodeBlockActionButtonPlugin } from '@/lib/editor/milkdown/codeBlockDeleteButton'
import { configureImagePath } from '@/lib/editor/milkdown/imagePathPlugin'
import { configureExternalLink } from '@/lib/editor/milkdown/externalLinkPlugin'
import GithubSlugger from 'github-slugger'
import { stripMarkdown, type TOCItem } from '@/lib/utils'
interface UseMarkdownLogicProps {
content: string
filePath: string
readOnly?: boolean
onChange?: (markdown: string) => void
handleClickOn?: (view: EditorView, pos: number, node: Node, nodePos: number, event: MouseEvent) => boolean
onMathBlockCreated?: (view: EditorView, node: Node, nodePos: number) => void
onTocUpdated?: (toc: TOCItem[]) => void
}
const getTocFromDoc = (doc: Node): TOCItem[] => {
const items: TOCItem[] = []
const stack: TOCItem[] = []
const slugger = new GithubSlugger()
doc.descendants((node) => {
if (node.type.name === 'heading') {
const level = node.attrs.level
const text = node.textContent
// Prefer using existing ID if available to match rendered output, fallback to generation
const id = node.attrs.id || slugger.slug(stripMarkdown(text))
const item: TOCItem = {
id,
text,
level,
children: []
}
while (stack.length > 0 && stack[stack.length - 1].level >= level) {
stack.pop()
}
if (stack.length === 0) {
items.push(item)
} else {
stack[stack.length - 1].children.push(item)
}
stack.push(item)
}
// Do not descend into heading nodes
return false
})
return items
}
export const useMarkdownLogic = ({
content,
filePath,
readOnly = false,
onChange,
handleClickOn,
onMathBlockCreated,
onTocUpdated
}: UseMarkdownLogicProps) => {
const onChangeRef = useRef(onChange)
const handleClickOnRef = useRef(handleClickOn)
const onMathBlockCreatedRef = useRef(onMathBlockCreated)
const onTocUpdatedRef = useRef(onTocUpdated)
const readOnlyRef = useRef(readOnly)
const ctxRef = useRef<Ctx | null>(null)
useEffect(() => {
onChangeRef.current = onChange
}, [])
useEffect(() => {
handleClickOnRef.current = handleClickOn
}, [])
useEffect(() => {
onMathBlockCreatedRef.current = onMathBlockCreated
}, [])
useEffect(() => {
onTocUpdatedRef.current = onTocUpdated
}, [])
useEffect(() => {
readOnlyRef.current = readOnly
}, [])
// 动态更新可编辑状态,无需重建编辑器
useEffect(() => {
if (!ctxRef.current) return
try {
const view = ctxRef.current.get(editorViewCtx)
view.setProps({ editable: () => !readOnly })
view.dispatch(view.state.tr.setMeta(codeBlockActionButtonRefreshMetaKey, true))
} catch {
// 编辑器可能尚未就绪
}
}, [readOnly])
// 在只读模式下动态更新内容
useEffect(() => {
if (!ctxRef.current || !readOnly) return
try {
const view = ctxRef.current.get(editorViewCtx)
const parser = ctxRef.current.get(parserCtx)
const doc = parser(content)
if (!doc) return
const state = view.state
view.dispatch(state.tr.replaceWith(0, state.doc.content.size, doc))
} catch {
// 编辑器可能尚未就绪
}
}, [content, readOnly])
return useEditor((root) => {
return Editor.make()
.config((ctx) => {
ctxRef.current = ctx
ctx.set(rootCtx, root)
ctx.set(defaultValueCtx, content)
// Configure custom heading ID generator to match TOC logic
// Milkdown's syncHeadingIdPlugin will handle deduplication (adding -1, -2 suffixes)
// We just need to provide the base slug consistent with src/lib/markdown.ts
ctx.set(headingIdGenerator.key, (node) => {
const slugger = new GithubSlugger()
return slugger.slug(stripMarkdown(node.textContent))
})
// Configure custom image path resolution and external link behavior
configureImagePath(ctx, filePath)
configureExternalLink(ctx)
ctx.update(remarkCtx, (builder) => builder.use(remarkBreaks))
ctx.set(prismConfig.key, {
configureRefractor: (currentRefractor) => {
registerCommonRefractorLanguages(currentRefractor)
},
})
// Initial configuration
ctx.update(editorViewOptionsCtx, (prev) => ({
...prev,
editable: () => !readOnly,
handleClickOn: (view, pos, node, nodePos, event) => {
if (handleClickOnRef.current) {
return handleClickOnRef.current(view, pos, node, nodePos, event)
}
return false
}
}))
// 配置变更监听器
ctx.get(listenerCtx).markdownUpdated((_, markdown, prevMarkdown) => {
if (markdown !== prevMarkdown) {
if (onChangeRef.current) {
onChangeRef.current(markdown)
}
if (onTocUpdatedRef.current) {
try {
const view = ctx.get(editorViewCtx)
const toc = getTocFromDoc(view.state.doc)
onTocUpdatedRef.current(toc)
} catch {
// 忽略
}
}
}
})
// Configure Math Block Creation Listener
ctx.update(prosePluginsCtx, (prev) => [
...prev,
new Plugin({
key: new PluginKey('math-block-creation-watcher'),
appendTransaction: (transactions, _oldState, newState) => {
const { selection } = newState
const { $from } = selection
const node = $from.parent
// Check if we are inside a newly created empty math block
if (node.type.name === 'math_block' && (node.attrs.value === '' || !node.attrs.value) && node.textContent === '') {
const docChanged = transactions.some(tr => tr.docChanged)
if (docChanged) {
setTimeout(() => {
if (onMathBlockCreatedRef.current) {
try {
const view = ctx.get(editorViewCtx)
onMathBlockCreatedRef.current(view, node, $from.before())
} catch {
// View might not be ready
}
}
}, 0)
}
}
return null
}
}),
createClipboardImageUploaderPlugin({
isReadOnly: () => readOnlyRef.current,
parseMarkdown: (markdown) => ctx.get(parserCtx)(markdown)
}),
createCodeBlockActionButtonPlugin({ isReadOnly: () => readOnlyRef.current })
])
// Initial TOC update
setTimeout(() => {
if (onTocUpdatedRef.current) {
try {
const view = ctx.get(editorViewCtx)
const toc = getTocFromDoc(view.state.doc)
onTocUpdatedRef.current(toc)
} catch {
// ignore
}
}
}, 0)
})
.use(commonmark)
.use(gfm)
.use(prism)
.use(math)
.use(block)
.use(history)
.use(listener)
}, []) // 空依赖数组以防止重新挂载
}

View File

@@ -0,0 +1,122 @@
import { useCallback, useState, useEffect, useRef } from 'react'
import type { FileItem } from '@/lib/api'
import type { TOCItem } from '@/lib/utils'
import { useTabStore, isSpecialTab, getDerivedEditState } from '@/stores'
import { useTimeTracker } from '@/modules/time-tracking'
import { ASYNC_IMPORT_STATUS } from '@shared/constants'
import { useSidebarState } from '../ui/useSidebarState'
import { useTOCState } from '../ui/useTOCState'
import { useKeyboardShortcuts } from '../ui/useKeyboardShortcuts'
export interface UseNoteBrowserReturn {
sidebarOpen: boolean
setSidebarOpen: React.Dispatch<React.SetStateAction<boolean>>
refreshKey: number
bumpRefresh: () => void
showTOC: boolean
tocItems: TOCItem[]
showTOCButton: boolean
bgTimestamp: number
handleTOCClick: () => void
handleTOCClose: () => void
handleTOCItemClick: (id: string) => void
handleTocUpdated: (filePath: string, items: TOCItem[]) => void
handleSave: () => Promise<void>
handleToggleEdit: () => void
isAsyncImportProcessing: boolean
}
export function useNoteBrowser(): UseNoteBrowserReturn {
const [bgTimestamp, setBgTimestamp] = useState(Date.now())
const { trackTabSwitch } = useTimeTracker()
const prevSelectedFileRef = useRef<FileItem | null>(null)
const { tabs, activeTabId, toggleEditing, saveContent } = useTabStore()
const selectedFile = activeTabId ? tabs.get(activeTabId)?.file ?? null : null
const tab = activeTabId ? tabs.get(activeTabId) : undefined
const derivedState = getDerivedEditState(selectedFile?.path ?? null)
const isAsyncImportProcessing = selectedFile ? (() => {
if (!tab) return false
const trimmed = tab.content.trimStart()
return (
trimmed.startsWith(ASYNC_IMPORT_STATUS.PDF_PARSING_TITLE) ||
trimmed.startsWith(ASYNC_IMPORT_STATUS.HTML_PARSING_TITLE)
)
})() : false
useEffect(() => {
if (selectedFile && selectedFile !== prevSelectedFileRef.current) {
trackTabSwitch({
tabId: selectedFile.path,
filePath: selectedFile.path
})
prevSelectedFileRef.current = selectedFile
}
}, [selectedFile, trackTabSwitch])
const { sidebarOpen, setSidebarOpen, refreshKey, bumpRefresh } = useSidebarState()
const {
showTOC,
tocItems,
showTOCButton,
handleTOCClick,
handleTOCClose,
handleTOCItemClick,
handleTocUpdated
} = useTOCState(selectedFile)
const handleSave = useCallback(async () => {
if (!selectedFile) return
try {
await saveContent(selectedFile.path)
} catch (error) {
console.error('Failed to save', error)
alert('Failed to save file')
}
}, [selectedFile, saveContent])
const handleToggleEdit = useCallback(() => {
if (selectedFile) {
toggleEditing(selectedFile.path)
}
}, [selectedFile, toggleEditing])
useKeyboardShortcuts({
selectedFile,
isSpecialTab: (file) => file ? isSpecialTab(file) : false,
isAsyncImportProcessing,
isCurrentTabEditing: derivedState.isCurrentTabEditing,
hasCurrentTabUnsavedChanges: derivedState.hasCurrentTabUnsavedChanges,
handleSave,
toggleTabEdit: toggleEditing
})
useEffect(() => {
const handleWallpaperChange = () => {
setBgTimestamp(Date.now())
}
window.addEventListener('wallpaper-changed', handleWallpaperChange)
return () => window.removeEventListener('wallpaper-changed', handleWallpaperChange)
}, [])
return {
sidebarOpen,
setSidebarOpen,
refreshKey,
bumpRefresh,
showTOC,
tocItems,
showTOCButton,
bgTimestamp,
handleTOCClick,
handleTOCClose,
handleTOCItemClick,
handleTocUpdated,
handleSave,
handleToggleEdit,
isAsyncImportProcessing
}
}

View File

@@ -0,0 +1,134 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { FileItem } from '@/lib/api'
import { fetchFileContent, saveFileContent } from '@/lib/api'
import { isAsyncImportProcessingContent } from '@shared/constants'
export const useNoteContent = (selectedFile: FileItem | null) => {
// --- State ---
const [content, setContent] = useState<string>('')
const [loading, setLoading] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [unsavedContent, setUnsavedContent] = useState('')
// 用于异步操作跟踪最新内容的 Ref
const contentRef = useRef(content)
// 将 ref 与内容同步
useEffect(() => {
contentRef.current = content
}, [content])
// --- Loader Logic ---
const loadFile = useCallback(async (path: string, keepScroll = false) => {
if (!contentRef.current) setLoading(true)
const scrollContainer = document.getElementById('note-content-scroll')
const scrollTop = scrollContainer ? scrollContainer.scrollTop : 0
try {
const data = await fetchFileContent(path)
// 检查内容是否实际更改以避免冗余更新/闪烁
if (data.content !== contentRef.current) {
setContent(data.content)
contentRef.current = data.content
}
// 将未保存内容与加载的内容同步
setUnsavedContent(data.content)
// 加载新文件时确保不处于编辑模式
setIsEditing(false)
requestAnimationFrame(() => {
const container = document.getElementById('note-content-scroll')
if (!container) return
container.scrollTop = keepScroll ? scrollTop : 0
})
} catch (error) {
console.error('Failed to load content', error)
const errorMsg = '# Error\nFailed to load file content.'
setContent(errorMsg)
contentRef.current = errorMsg
setUnsavedContent(errorMsg)
} finally {
setLoading(false)
}
}, [])
const resetContent = useCallback(() => {
setContent('')
contentRef.current = ''
setUnsavedContent('')
setIsEditing(false)
}, [])
const isAsyncImportProcessing = isAsyncImportProcessingContent(content)
// --- Edit Mode Logic ---
const toggleEdit = useCallback(() => {
if (isAsyncImportProcessing) return
// 切换时,我们始终将未保存内容重置为当前已提交的内容。
// 这在退出编辑模式但不保存时充当"取消"操作,
// 在进入编辑模式时充当"初始化"操作。
setUnsavedContent(content)
setIsEditing(prev => !prev)
}, [content, isAsyncImportProcessing])
// --- Saver Logic ---
const save = useCallback(async () => {
if (!selectedFile) return
if (isAsyncImportProcessing) return
// 保存当前未保存内容中的内容
const contentToSave = unsavedContent
try {
await saveFileContent(selectedFile.path, contentToSave)
// 更新已提交状态
setContent(contentToSave)
contentRef.current = contentToSave
// 成功保存后切换到只读模式
requestAnimationFrame(() => {
setIsEditing(false)
})
} catch (e) {
console.error("Save failed", e)
throw e
}
}, [selectedFile, unsavedContent, isAsyncImportProcessing])
// --- Effects ---
// 当选中文件更改时加载文件
useEffect(() => {
if (!selectedFile) return
// 立即重置编辑模式以防止显示上一个文件的状态
setIsEditing(false)
loadFile(selectedFile.path, false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedFile?.path])
// 当没有选中文件时重置内容
useEffect(() => {
if (selectedFile) return
resetContent()
}, [selectedFile, resetContent])
return {
content,
unsavedContent,
setUnsavedContent,
loading,
isAsyncImportProcessing,
isEditing,
toggleEdit,
save,
loadFile,
setContent,
setIsEditing
}
}

View File

@@ -0,0 +1,23 @@
import { useCallback } from 'react'
import { uploadPdfForParsing } from '@/lib/api'
import { useFileImport } from './useFileImport'
import { ASYNC_IMPORT_STATUS } from '@shared/constants'
export function usePDFImport() {
const { importFile } = useFileImport()
const importPDF = useCallback(async (file: File, currentPath: string) => {
return importFile(
{ file, currentPath },
{
placeholderContent: ASYNC_IMPORT_STATUS.PDF_PARSING_CONTENT,
uploadFunction: (f, targetPath) => uploadPdfForParsing(f as File, targetPath),
getBaseName: (pdfName) => pdfName.endsWith('.pdf') ? pdfName.slice(0, -4) : pdfName
}
)
}, [importFile])
return {
importPDF
}
}

View File

@@ -0,0 +1,50 @@
import { useCallback, useState } from 'react'
interface UnsavedChangesHandlerProps {
isEditing: boolean
hasUnsavedChanges: boolean
}
interface UnsavedChangesHandlerReturn {
isConfirmOpen: boolean
requestAction: (action: () => void) => void
handleConfirm: () => void
handleCancel: () => void
}
export const useUnsavedChangesHandler = ({
isEditing,
hasUnsavedChanges
}: UnsavedChangesHandlerProps): UnsavedChangesHandlerReturn => {
const [isConfirmOpen, setIsConfirmOpen] = useState(false)
const [pendingAction, setPendingAction] = useState<(() => void) | null>(null)
const requestAction = useCallback((action: () => void) => {
if (isEditing && hasUnsavedChanges) {
setPendingAction(() => action)
setIsConfirmOpen(true)
} else {
action()
}
}, [isEditing, hasUnsavedChanges])
const handleConfirm = useCallback(() => {
if (pendingAction) {
pendingAction()
}
setIsConfirmOpen(false)
setPendingAction(null)
}, [pendingAction])
const handleCancel = useCallback(() => {
setIsConfirmOpen(false)
setPendingAction(null)
}, [])
return {
isConfirmOpen,
requestAction,
handleConfirm,
handleCancel
}
}

View File

@@ -0,0 +1 @@
export { useNotebookEvents } from './useNotebookEvents'

View File

@@ -0,0 +1,77 @@
import { useEffect, useRef, useState } from 'react'
import type { FileItem } from '@/lib/api'
type NotebookEvent = {
event: string
path?: string
}
export const useNotebookEvents = (options: {
selectedFile: FileItem | null
onRefresh: () => void
onFileChanged: (filePath: string) => void
}) => {
const { selectedFile, onRefresh, onFileChanged } = options
const [sseEnabled, setSseEnabled] = useState(true)
const pollingRef = useRef<number | null>(null)
const onRefreshRef = useRef(onRefresh)
const onFileChangedRef = useRef(onFileChanged)
const selectedFileRef = useRef(selectedFile)
useEffect(() => {
onRefreshRef.current = onRefresh
onFileChangedRef.current = onFileChanged
selectedFileRef.current = selectedFile
}, [])
useEffect(() => {
if (!sseEnabled) return
const eventSource = new EventSource('/api/events')
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data) as NotebookEvent
if (
data.event === 'change' ||
data.event === 'add' ||
data.event === 'unlink' ||
data.event === 'addDir' ||
data.event === 'unlinkDir'
) {
onRefreshRef.current()
const currentFile = selectedFileRef.current
if (currentFile && data.path && data.path === currentFile.path) {
onFileChangedRef.current(currentFile.path)
}
}
}
eventSource.onerror = () => {
eventSource.close()
setSseEnabled(false)
}
return () => {
eventSource.close()
}
}, [sseEnabled])
useEffect(() => {
if (sseEnabled) return
if (pollingRef.current) return
pollingRef.current = window.setInterval(() => {
onRefreshRef.current()
}, 10000)
return () => {
if (pollingRef.current) {
window.clearInterval(pollingRef.current)
pollingRef.current = null
}
}
}, [sseEnabled])
return { sseEnabled }
}

6
src/hooks/index.ts Normal file
View File

@@ -0,0 +1,6 @@
export * from './ui'
export * from './domain'
export * from './events'
export * from './utils'
export { useAutoLoadTabContent } from './domain'

4
src/hooks/ui/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export { useSidebarState, type UseSidebarStateReturn } from './useSidebarState'
export { useSidebarResize } from './useSidebarResize'
export { useTOCState, type UseTOCStateReturn } from './useTOCState'
export { useKeyboardShortcuts, type UseKeyboardShortcutsParams } from './useKeyboardShortcuts'

View File

@@ -0,0 +1,54 @@
import { useEffect } from 'react'
import type { FileItem } from '@/lib/api'
export interface UseKeyboardShortcutsParams {
selectedFile: FileItem | null
isSpecialTab: (file: FileItem | null) => boolean
isAsyncImportProcessing: boolean
isCurrentTabEditing: boolean
hasCurrentTabUnsavedChanges: boolean
handleSave: () => Promise<void>
toggleTabEdit: (path: string) => void
}
export function useKeyboardShortcuts({
selectedFile,
isSpecialTab,
isAsyncImportProcessing,
isCurrentTabEditing,
hasCurrentTabUnsavedChanges,
handleSave,
toggleTabEdit
}: UseKeyboardShortcutsParams): void {
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (isCurrentTabEditing && hasCurrentTabUnsavedChanges) {
e.preventDefault()
e.returnValue = ''
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [isCurrentTabEditing, hasCurrentTabUnsavedChanges])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (isSpecialTab(selectedFile) || isAsyncImportProcessing) return
if (e.ctrlKey && e.key === 's') {
e.preventDefault()
if (isCurrentTabEditing) {
handleSave()
}
} else if (e.ctrlKey && e.key === 'e') {
e.preventDefault()
if (selectedFile) {
toggleTabEdit(selectedFile.path)
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [selectedFile, isSpecialTab, isAsyncImportProcessing, isCurrentTabEditing, handleSave, toggleTabEdit])
}

View File

@@ -0,0 +1,33 @@
import { useCallback, useEffect, useState } from 'react'
export const useSidebarResize = (initialWidth: number = 250) => {
const [sidebarWidth, setSidebarWidth] = useState(initialWidth)
const [isResizing, setIsResizing] = useState(false)
const startResizing = useCallback((e: React.MouseEvent) => {
e.preventDefault()
setIsResizing(true)
}, [])
useEffect(() => {
if (!isResizing) return
const stopResizing = () => setIsResizing(false)
const resize = (e: MouseEvent) => {
const newWidth = e.clientX
if (newWidth > 150 && newWidth < 600) {
setSidebarWidth(newWidth)
}
}
window.addEventListener('mousemove', resize)
window.addEventListener('mouseup', stopResizing)
return () => {
window.removeEventListener('mousemove', resize)
window.removeEventListener('mouseup', stopResizing)
}
}, [isResizing])
return { sidebarWidth, startResizing }
}

View File

@@ -0,0 +1,22 @@
import { useState, useCallback } from 'react'
export interface UseSidebarStateReturn {
sidebarOpen: boolean
setSidebarOpen: React.Dispatch<React.SetStateAction<boolean>>
refreshKey: number
bumpRefresh: () => void
}
export function useSidebarState(): UseSidebarStateReturn {
const [sidebarOpen, setSidebarOpen] = useState(true)
const [refreshKey, setRefreshKey] = useState(0)
const bumpRefresh = useCallback(() => setRefreshKey((prev) => prev + 1), [])
return {
sidebarOpen,
setSidebarOpen,
refreshKey,
bumpRefresh
}
}

View File

@@ -0,0 +1,60 @@
import { useState, useCallback } from 'react'
import type { FileItem } from '@/lib/api'
import type { TOCItem } from '@/lib/utils'
import { scrollToElementInTab } from '@/components/tabs'
export interface UseTOCStateReturn {
showTOC: boolean
tocItems: TOCItem[]
showTOCButton: boolean
handleTOCClick: () => void
handleTOCClose: () => void
handleTOCItemClick: (id: string) => void
handleTocUpdated: (filePath: string, items: TOCItem[]) => void
}
export function useTOCState(selectedFile: FileItem | null): UseTOCStateReturn {
const [showTOC, setShowTOC] = useState(false)
const [tocItems, setTocItems] = useState<TOCItem[]>([])
const [showTOCButton, setShowTOCButton] = useState(true)
const handleTocUpdated = useCallback((filePath: string, items: TOCItem[]) => {
if (selectedFile?.path === filePath) {
setTocItems(items)
}
}, [selectedFile])
const handleTOCClick = useCallback(() => {
const newState = !showTOC
setShowTOC(newState)
if (newState) {
setTimeout(() => {
setShowTOCButton(false)
}, 300)
} else {
setShowTOCButton(true)
}
}, [showTOC])
const handleTOCClose = useCallback(() => {
setShowTOC(false)
setShowTOCButton(true)
}, [])
const handleTOCItemClick = useCallback((id: string) => {
if (selectedFile) {
scrollToElementInTab(id, selectedFile.path)
}
}, [selectedFile])
return {
showTOC,
tocItems,
showTOCButton,
handleTOCClick,
handleTOCClose,
handleTOCItemClick,
handleTocUpdated
}
}

2
src/hooks/utils/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { useLocalStorageState } from './useLocalStorageState'
export { useClickOutside } from './useClickOutside'

View File

@@ -0,0 +1,47 @@
import { useEffect, RefObject } from 'react'
interface UseClickOutsideOptions {
enabled?: boolean
eventType?: 'mousedown' | 'click'
includeEscape?: boolean
}
export const useClickOutside = (
ref: RefObject<HTMLElement | null>,
callback: () => void,
options: UseClickOutsideOptions = {}
) => {
const {
enabled = true,
eventType = 'mousedown',
includeEscape = false
} = options
useEffect(() => {
if (!enabled) return
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
callback()
}
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
callback()
}
}
document.addEventListener(eventType, handleClickOutside)
if (includeEscape) {
document.addEventListener('keydown', handleEscape)
}
return () => {
document.removeEventListener(eventType, handleClickOutside)
if (includeEscape) {
document.removeEventListener('keydown', handleEscape)
}
}
}, [ref, callback, enabled, eventType, includeEscape])
}

View File

@@ -0,0 +1,36 @@
import { useState, useEffect } from 'react'
export function useLocalStorageState<T>(
key: string,
defaultValue: T,
options?: {
serialize?: (value: T) => string
deserialize?: (value: string) => T
validate?: (value: unknown) => value is T
}
): [T, (value: T | ((prev: T) => T)) => void] {
const { serialize = String, deserialize, validate } = options ?? {}
const [state, setState] = useState<T>(() => {
try {
const saved = localStorage.getItem(key)
if (saved !== null) {
const parsed = deserialize ? deserialize(saved) : (saved as unknown as T)
if (validate ? validate(parsed) : true) return parsed
}
} catch {
// 忽略
}
return defaultValue
})
useEffect(() => {
try {
localStorage.setItem(key, serialize(state))
} catch {
// 忽略
}
}, [key, state, serialize])
return [state, setState]
}

142
src/index.css Normal file
View File

@@ -0,0 +1,142 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-size: 20px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--app-header-bg-rgb: 255, 255, 255;
--app-content-bg-rgb: 255, 255, 255;
--app-sidebar-bg-rgb: 249, 250, 251;
--app-tabbar-bg-rgb: 229, 231, 235;
--app-toc-bg-rgb: 249, 250, 251;
--app-tab-active-bg-rgb: 255, 255, 255;
--app-tab-inactive-bg-rgb: 249, 250, 251;
--app-editor-text-color: #111827;
--app-scrollbar-thumb: #cbd5e1;
--app-scrollbar-thumb-hover: #94a3b8;
--titlebar-height: 36px;
/* Add titlebar height */
}
.dark {
--app-header-bg-rgb: 23, 23, 23;
--app-content-bg-rgb: 23, 23, 23;
--app-sidebar-bg-rgb: 38, 38, 38;
--app-tabbar-bg-rgb: 30, 30, 30;
--app-toc-bg-rgb: 38, 38, 38;
--app-tab-active-bg-rgb: 23, 23, 23;
--app-tab-inactive-bg-rgb: 38, 38, 38;
--app-editor-text-color: #e5e5e5;
--app-scrollbar-thumb: #525252;
--app-scrollbar-thumb-hover: #737373;
}
/* Electron Titlebar Drag Region */
.titlebar-drag-region {
-webkit-app-region: drag;
height: var(--titlebar-height);
width: 100%;
/* No absolute positioning to allow flex layout */
z-index: 50;
/* Ensure clicks pass through to elements below unless explicitly handled */
pointer-events: none;
}
/* Allow clicking on elements inside the drag region or below it if needed */
.titlebar-no-drag {
-webkit-app-region: no-drag;
pointer-events: auto;
}
html,
body,
#root {
height: 100%;
width: 100%;
overflow: hidden;
margin: 0;
padding: 0;
font-size: inherit;
}
/* Custom scrollbar for Webkit (Chrome, Safari, Edge) */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--app-scrollbar-thumb);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--app-scrollbar-thumb-hover);
}
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none;
/* IE and Edge */
scrollbar-width: none;
/* Firefox */
}
.milkdown-editor-wrapper {
font-family: inherit;
}
.milkdown {
height: 100%;
}
.milkdown .milkdown-editor {
min-height: 100%;
padding: 16px;
}
.milkdown .ProseMirror {
min-height: 100%;
outline: none;
color: var(--app-editor-text-color);
}
@layer components {
.milkdown-editor-wrapper .ProseMirror {
@apply prose prose-neutral max-w-none;
}
.dark .milkdown-editor-wrapper .ProseMirror {
@apply prose-invert;
}
.milkdown-editor-wrapper .ProseMirror img {
@apply max-w-full h-auto rounded-lg shadow-md my-4;
}
.milkdown-editor-wrapper .ProseMirror pre {
@apply rounded-lg;
}
.milkdown-editor-wrapper .ProseMirror div[data-type="math_block"] {
@apply my-8;
}
}

122
src/lib/api/client.ts Normal file
View File

@@ -0,0 +1,122 @@
import type { FileContentDTO, FileItemDTO, PathExistsDTO, SettingsDTO } from '@shared/types'
import { fetchApi } from './http'
export type FileItem = FileItemDTO
export type FileContent = FileContentDTO
export type Settings = SettingsDTO
export type UploadedImage = { name: string; path: string }
export const searchFiles = async (keywords: string[]): Promise<FileItem[]> => {
const data = await fetchApi<{ items: FileItem[] }>('/api/search', {
method: 'POST',
body: { keywords },
})
return data.items
}
export const fetchFiles = async (path: string = ''): Promise<FileItem[]> => {
const data = await fetchApi<{ items: FileItem[] }>(`/api/files?path=${encodeURIComponent(path)}`)
return data.items
}
export const fetchFileContent = async (path: string): Promise<FileContent> => {
return await fetchApi<FileContent>(`/api/files/content?path=${encodeURIComponent(path)}`)
}
export const saveFileContent = async (path: string, content: string): Promise<void> => {
await fetchApi<null>('/api/files/save', {
method: 'POST',
body: { path, content },
})
}
export const uploadClipboardImage = async (image: string): Promise<UploadedImage> => {
return await fetchApi<UploadedImage>('/api/files/upload/image', {
method: 'POST',
body: { image },
})
}
export const deleteFile = async (path: string): Promise<void> => {
await fetchApi<null>('/api/files/delete', {
method: 'DELETE',
body: { path },
})
}
export const createDirectory = async (path: string): Promise<void> => {
await fetchApi<null>('/api/files/create/dir', {
method: 'POST',
body: { path },
})
}
export const createFile = async (path: string): Promise<void> => {
await fetchApi<null>('/api/files/create/file', {
method: 'POST',
body: { path },
})
}
export const checkPathExists = async (path: string): Promise<PathExistsDTO> => {
return await fetchApi<PathExistsDTO>('/api/files/exists', {
method: 'POST',
body: { path },
})
}
export const renameItem = async (oldPath: string, newPath: string): Promise<void> => {
await fetchApi<null>('/api/files/rename', {
method: 'POST',
body: { oldPath, newPath },
})
}
export const runAiTask = async (task: string, path: string): Promise<void> => {
await fetchApi<null>('/api/ai/doubao', {
method: 'POST',
body: { task, path },
})
}
export const getSettings = async (): Promise<Settings> => {
return await fetchApi<Settings>('/api/settings')
}
export const saveSettings = async (settings: Settings): Promise<Settings> => {
return await fetchApi<Settings>('/api/settings', {
method: 'POST',
body: settings,
})
}
export const uploadPdfForParsing = async (file: File, targetPath: string): Promise<void> => {
const formData = new FormData()
formData.append('file', file)
formData.append('targetPath', targetPath)
await fetchApi<void>('/api/mineru/parse', {
method: 'POST',
body: formData,
})
}
export interface LocalHtmlInfo {
htmlPath: string
htmlDir: string
assetsDirName?: string
assetsFiles?: string[]
}
export const parseLocalHtml = async (info: LocalHtmlInfo, targetPath: string): Promise<void> => {
await fetchApi<void>('/api/blog/parse-local', {
method: 'POST',
body: {
htmlPath: info.htmlPath,
htmlDir: info.htmlDir,
assetsDirName: info.assetsDirName,
assetsFiles: info.assetsFiles,
targetPath,
},
})
}

View File

@@ -0,0 +1,104 @@
import { fetchApi } from './http'
import type { RequestOptions } from './modules/types'
import type { ModuleDefinition, ModuleEndpoints } from '@shared/modules/types'
export interface EndpointConfig {
path: string
method: string
}
export class ModuleApiInstance<TEndpoints extends ModuleEndpoints> {
readonly moduleId: string
readonly basePath: string
readonly endpoints: TEndpoints
constructor(definition: ModuleDefinition<string, TEndpoints>) {
this.moduleId = definition.id
this.basePath = definition.basePath
this.endpoints = definition.endpoints ?? ({} as TEndpoints)
}
protected buildPath(path: string, pathParams?: Record<string, string | number>): string {
let result = path
if (pathParams) {
for (const [key, value] of Object.entries(pathParams)) {
result = result.replace(`:${key}`, String(value))
}
}
return result
}
protected buildQueryParams(params?: Record<string, string | number | boolean | undefined>): string {
if (!params) return ''
const searchParams = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) {
searchParams.append(key, String(value))
}
}
const queryString = searchParams.toString()
return queryString ? `?${queryString}` : ''
}
protected getEndpointConfig(endpoint: keyof TEndpoints): EndpointConfig {
const ep = this.endpoints[endpoint]
if (!ep) {
throw new Error(`Endpoint '${String(endpoint)}' not found in module '${this.moduleId}'`)
}
return {
path: ep.path,
method: ep.method,
}
}
protected buildUrl(endpoint: keyof TEndpoints, options?: RequestOptions): string {
const endpointConfig = this.getEndpointConfig(endpoint)
const fullPath = `/api${this.basePath}${endpointConfig.path}`
const path = this.buildPath(fullPath, options?.pathParams)
const queryString = this.buildQueryParams(options?.queryParams)
return `${path}${queryString}`
}
async get<T>(endpoint: keyof TEndpoints, options?: RequestOptions): Promise<T | undefined> {
const url = this.buildUrl(endpoint, options)
return fetchApi<T>(url)
}
async post<T>(endpoint: keyof TEndpoints, body: unknown, options?: RequestOptions): Promise<T | undefined> {
const url = this.buildUrl(endpoint, options)
return fetchApi<T>(url, {
method: 'POST',
body,
})
}
async put<T>(endpoint: keyof TEndpoints, body: unknown, options?: RequestOptions): Promise<T | undefined> {
const url = this.buildUrl(endpoint, options)
return fetchApi<T>(url, {
method: 'PUT',
body,
})
}
async delete<T>(endpoint: keyof TEndpoints, body?: unknown, options?: RequestOptions): Promise<T | undefined> {
const url = this.buildUrl(endpoint, options)
return fetchApi<T>(url, {
method: 'DELETE',
body,
})
}
async postFormData<T>(endpoint: keyof TEndpoints, formData: FormData): Promise<T | undefined> {
const url = this.buildUrl(endpoint)
return fetchApi<T>(url, {
method: 'POST',
body: formData,
})
}
}
export function createModuleApi<TEndpoints extends ModuleEndpoints>(
definition: ModuleDefinition<string, TEndpoints>
): ModuleApiInstance<TEndpoints> {
return new ModuleApiInstance(definition)
}

71
src/lib/api/http.ts Normal file
View File

@@ -0,0 +1,71 @@
import type { ApiResponse, ApiErrorDTO } from '@shared/types'
export class HttpError extends Error {
status: number
code: string
details?: unknown
constructor(options: { status: number; code: string; message: string; details?: unknown }) {
super(options.message)
this.status = options.status
this.code = options.code
this.details = options.details
}
}
const isFailure = <T>(body: ApiResponse<T>): body is { success: false; error: ApiErrorDTO } => body.success === false
export const fetchApi = async <T>(input: RequestInfo | URL, init?: RequestInit | (Omit<RequestInit, 'body'> & { body?: unknown })): Promise<T | undefined> => {
const headers = new Headers(init?.headers)
let body = init?.body
if (
body &&
typeof body === 'object' &&
!(body instanceof FormData) &&
!(body instanceof Blob) &&
!(body instanceof URLSearchParams) &&
!(body instanceof ReadableStream) &&
!(body instanceof ArrayBuffer) &&
!ArrayBuffer.isView(body)
) {
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
body = JSON.stringify(body)
}
const res = await fetch(input, { ...init, headers, body: body as BodyInit })
const isJson = res.headers.get('content-type')?.includes('application/json')
if (!isJson) {
if (!res.ok) {
throw new HttpError({ status: res.status, code: 'HTTP_ERROR', message: res.statusText })
}
return undefined
}
const responseBody = (await res.json()) as ApiResponse<T>
if (!res.ok) {
if (isFailure(responseBody)) {
throw new HttpError({
status: res.status,
code: responseBody.error.code,
message: responseBody.error.message,
details: responseBody.error.details,
})
}
throw new HttpError({ status: res.status, code: 'HTTP_ERROR', message: res.statusText })
}
if (isFailure(responseBody)) {
throw new HttpError({
status: 200,
code: responseBody.error.code,
message: responseBody.error.message,
details: responseBody.error.details,
})
}
return responseBody.data
}

20
src/lib/api/index.ts Normal file
View File

@@ -0,0 +1,20 @@
export { fetchApi, HttpError } from './http'
export type { FileItem, FileContent, Settings, UploadedImage, LocalHtmlInfo } from './client'
export {
searchFiles,
fetchFiles,
fetchFileContent,
saveFileContent,
uploadClipboardImage,
deleteFile,
createDirectory,
createFile,
checkPathExists,
renameItem,
runAiTask,
getSettings,
saveSettings,
uploadPdfForParsing,
parseLocalHtml,
} from './client'
export * from './modules'

View File

@@ -0,0 +1,35 @@
interface ModuleApiLike<TEndpoints = Record<string, unknown>> {
moduleId: string
basePath: string
endpoints: TEndpoints
get<T>(endpoint: string, options?: Record<string, unknown>): Promise<T | undefined>
post<T>(endpoint: string, body: unknown, options?: Record<string, unknown>): Promise<T | undefined>
put<T>(endpoint: string, body: unknown, options?: Record<string, unknown>): Promise<T | undefined>
delete<T>(endpoint: string, body?: unknown, options?: Record<string, unknown>): Promise<T | undefined>
postFormData<T>(endpoint: string, formData: FormData): Promise<T | undefined>
}
class ModuleApiRegistryImpl {
private apis: Map<string, ModuleApiLike> = new Map()
register<TEndpoints extends Record<string, unknown>>(api: ModuleApiLike<TEndpoints>): void {
if (this.apis.has(api.moduleId)) {
console.warn(`Module API "${api.moduleId}" is already registered. Overwriting.`)
}
this.apis.set(api.moduleId, api as ModuleApiLike)
}
get<T extends ModuleApiLike>(moduleId: string): T | undefined {
return this.apis.get(moduleId) as T | undefined
}
getAll(): Map<string, ModuleApiLike> {
return new Map(this.apis)
}
has(moduleId: string): boolean {
return this.apis.has(moduleId)
}
}
export const moduleApiRegistry = new ModuleApiRegistryImpl()

View File

@@ -0,0 +1,3 @@
export * from './types'
export { ModuleApiInstance, createModuleApi } from '../createModuleApi'
export * from './ModuleApiRegistry'

View File

@@ -0,0 +1,28 @@
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
export interface EndpointConfig {
path: string
method: HttpMethod
}
export interface RequestOptions {
queryParams?: Record<string, string | number | boolean | undefined>
pathParams?: Record<string, string | number>
body?: unknown
}
export interface ModuleApiConfig<TEndpoints> {
moduleId: string
basePath: string
endpoints: TEndpoints
}
export interface ModuleApi<TEndpoints> {
readonly moduleId: string
readonly config: ModuleApiConfig<TEndpoints>
get<T>(endpoint: keyof TEndpoints, options?: RequestOptions): Promise<T | undefined>
post<T>(endpoint: keyof TEndpoints, body: unknown, options?: RequestOptions): Promise<T | undefined>
put<T>(endpoint: keyof TEndpoints, body: unknown, options?: RequestOptions): Promise<T | undefined>
delete<T>(endpoint: keyof TEndpoints, body?: unknown, options?: RequestOptions): Promise<T | undefined>
postFormData<T>(endpoint: keyof TEndpoints, formData: FormData): Promise<T | undefined>
}

Some files were not shown because too many files have changed in this diff Show More