Initial commit
This commit is contained in:
25
src/App.tsx
Normal file
25
src/App.tsx
Normal 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
|
||||
32
src/components/common/ConfirmDialog/ConfirmDialog.tsx
Normal file
32
src/components/common/ConfirmDialog/ConfirmDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/components/common/ConfirmDialog/index.ts
Normal file
1
src/components/common/ConfirmDialog/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ConfirmDialog } from './ConfirmDialog'
|
||||
50
src/components/common/ContextMenu/ContextMenu.tsx
Normal file
50
src/components/common/ContextMenu/ContextMenu.tsx
Normal 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
|
||||
)
|
||||
}
|
||||
2
src/components/common/ContextMenu/index.ts
Normal file
2
src/components/common/ContextMenu/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ContextMenu } from './ContextMenu'
|
||||
export type { ContextMenuItem, ContextMenuProps } from './ContextMenu'
|
||||
78
src/components/common/DialogContent/DialogContent.tsx
Normal file
78
src/components/common/DialogContent/DialogContent.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/components/common/DialogContent/index.ts
Normal file
1
src/components/common/DialogContent/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { DialogContent } from './DialogContent'
|
||||
93
src/components/common/ErrorBoundary/ErrorBoundary.tsx
Normal file
93
src/components/common/ErrorBoundary/ErrorBoundary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
1
src/components/common/ErrorBoundary/index.ts
Normal file
1
src/components/common/ErrorBoundary/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ErrorBoundary } from './ErrorBoundary'
|
||||
56
src/components/common/Modal/Modal.tsx
Normal file
56
src/components/common/Modal/Modal.tsx
Normal 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
|
||||
)
|
||||
}
|
||||
1
src/components/common/Modal/index.ts
Normal file
1
src/components/common/Modal/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Modal } from './Modal'
|
||||
96
src/components/common/Select/Select.tsx
Normal file
96
src/components/common/Select/Select.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
2
src/components/common/Select/index.ts
Normal file
2
src/components/common/Select/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Select } from './Select'
|
||||
export type { SelectOption } from './Select'
|
||||
91
src/components/common/__tests__/ConfirmDialog.test.tsx
Normal file
91
src/components/common/__tests__/ConfirmDialog.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
120
src/components/common/__tests__/ErrorBoundary.test.tsx
Normal file
120
src/components/common/__tests__/ErrorBoundary.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
85
src/components/common/__tests__/Modal.test.tsx
Normal file
85
src/components/common/__tests__/Modal.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
114
src/components/dialogs/CreateItemDialog/CreateItemDialog.tsx
Normal file
114
src/components/dialogs/CreateItemDialog/CreateItemDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/components/dialogs/CreateItemDialog/index.ts
Normal file
1
src/components/dialogs/CreateItemDialog/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CreateItemDialog } from './CreateItemDialog'
|
||||
477
src/components/dialogs/CreatePyDemoDialog/CreatePyDemoDialog.tsx
Normal file
477
src/components/dialogs/CreatePyDemoDialog/CreatePyDemoDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/components/dialogs/CreatePyDemoDialog/index.ts
Normal file
1
src/components/dialogs/CreatePyDemoDialog/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CreatePyDemoDialog } from './CreatePyDemoDialog'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
1
src/components/dialogs/DeleteConfirmDialog/index.ts
Normal file
1
src/components/dialogs/DeleteConfirmDialog/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { DeleteConfirmDialog } from './DeleteConfirmDialog'
|
||||
64
src/components/dialogs/ExportDialog/ExportDialog.tsx
Normal file
64
src/components/dialogs/ExportDialog/ExportDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/components/dialogs/ExportDialog/index.ts
Normal file
1
src/components/dialogs/ExportDialog/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ExportDialog } from './ExportDialog'
|
||||
85
src/components/dialogs/FileSelectDialog/FileSelectDialog.tsx
Normal file
85
src/components/dialogs/FileSelectDialog/FileSelectDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/components/dialogs/FileSelectDialog/index.ts
Normal file
1
src/components/dialogs/FileSelectDialog/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { FileSelectDialog } from './FileSelectDialog'
|
||||
94
src/components/dialogs/HTMLSelectDialog/HTMLSelectDialog.tsx
Normal file
94
src/components/dialogs/HTMLSelectDialog/HTMLSelectDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/components/dialogs/HTMLSelectDialog/index.ts
Normal file
1
src/components/dialogs/HTMLSelectDialog/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { HTMLSelectDialog } from './HTMLSelectDialog'
|
||||
95
src/components/dialogs/PDFSelectDialog/PDFSelectDialog.tsx
Normal file
95
src/components/dialogs/PDFSelectDialog/PDFSelectDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/components/dialogs/PDFSelectDialog/index.ts
Normal file
1
src/components/dialogs/PDFSelectDialog/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PDFSelectDialog } from './PDFSelectDialog'
|
||||
212
src/components/editor/Markdown/Markdown.tsx
Normal file
212
src/components/editor/Markdown/Markdown.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
81
src/components/editor/Markdown/MathModal.tsx
Normal file
81
src/components/editor/Markdown/MathModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
199
src/components/editor/Markdown/styles.css
Normal file
199
src/components/editor/Markdown/styles.css
Normal 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;
|
||||
}
|
||||
71
src/components/editor/TOC/TOC.tsx
Normal file
71
src/components/editor/TOC/TOC.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
2
src/components/editor/TOC/index.ts
Normal file
2
src/components/editor/TOC/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { TOC } from './TOC'
|
||||
export type { TOCProps } from './TOC'
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
1
src/components/editor/UnsavedChangesHandler/index.ts
Normal file
1
src/components/editor/UnsavedChangesHandler/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { UnsavedChangesDialog } from './UnsavedChangesHandler'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
1
src/components/file-system/FileSystemManager/index.ts
Normal file
1
src/components/file-system/FileSystemManager/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { FileSystemManager } from './FileSystemManager'
|
||||
156
src/components/file-system/FileTree/FileTree.tsx
Normal file
156
src/components/file-system/FileTree/FileTree.tsx
Normal 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'
|
||||
1
src/components/file-system/FileTree/index.ts
Normal file
1
src/components/file-system/FileTree/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { FileTree } from './FileTree'
|
||||
234
src/components/file-system/Sidebar/Sidebar.tsx
Normal file
234
src/components/file-system/Sidebar/Sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/components/file-system/Sidebar/index.ts
Normal file
1
src/components/file-system/Sidebar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Sidebar } from './Sidebar'
|
||||
21
src/components/icons/PythonIcon/PythonIcon.tsx
Normal file
21
src/components/icons/PythonIcon/PythonIcon.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/components/icons/PythonIcon/index.ts
Normal file
1
src/components/icons/PythonIcon/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PythonIcon } from './PythonIcon'
|
||||
94
src/components/layout/ActivityBar/ActivityBar.tsx
Normal file
94
src/components/layout/ActivityBar/ActivityBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/components/layout/ActivityBar/index.ts
Normal file
1
src/components/layout/ActivityBar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ActivityBar } from './ActivityBar'
|
||||
108
src/components/layout/AppHeader/AppHeader.tsx
Normal file
108
src/components/layout/AppHeader/AppHeader.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
2
src/components/layout/AppHeader/index.ts
Normal file
2
src/components/layout/AppHeader/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AppHeader } from './AppHeader'
|
||||
export type { AppHeaderProps } from './AppHeader'
|
||||
150
src/components/layout/TabBar/TabBar.tsx
Normal file
150
src/components/layout/TabBar/TabBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
2
src/components/layout/TabBar/index.ts
Normal file
2
src/components/layout/TabBar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { TabBar } from './TabBar'
|
||||
export type { TabBarProps } from './TabBar'
|
||||
44
src/components/layout/TitleBar/TitleBar.tsx
Normal file
44
src/components/layout/TitleBar/TitleBar.tsx
Normal 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>
|
||||
)
|
||||
2
src/components/layout/TitleBar/index.ts
Normal file
2
src/components/layout/TitleBar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { TitleBar } from './TitleBar'
|
||||
export type { TitleBarProps } from './TitleBar'
|
||||
51
src/components/settings/SettingsSync/SettingsSync.tsx
Normal file
51
src/components/settings/SettingsSync/SettingsSync.tsx
Normal 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
|
||||
}
|
||||
1
src/components/settings/SettingsSync/index.ts
Normal file
1
src/components/settings/SettingsSync/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SettingsSync } from './SettingsSync'
|
||||
52
src/components/tabs/MarkdownTabPage/MarkdownTabPage.tsx
Normal file
52
src/components/tabs/MarkdownTabPage/MarkdownTabPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/components/tabs/MarkdownTabPage/index.ts
Normal file
1
src/components/tabs/MarkdownTabPage/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { MarkdownTabPage } from './MarkdownTabPage'
|
||||
80
src/components/tabs/TabContentCache/TabContentCache.tsx
Normal file
80
src/components/tabs/TabContentCache/TabContentCache.tsx
Normal 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'
|
||||
})
|
||||
}
|
||||
}
|
||||
1
src/components/tabs/TabContentCache/index.ts
Normal file
1
src/components/tabs/TabContentCache/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { TabContentCache, scrollToElementInTab } from './TabContentCache'
|
||||
2
src/components/tabs/index.ts
Normal file
2
src/components/tabs/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { TabContentCache, scrollToElementInTab } from './TabContentCache/TabContentCache'
|
||||
export { MarkdownTabPage } from './MarkdownTabPage/MarkdownTabPage'
|
||||
59
src/contexts/DragContext.tsx
Normal file
59
src/contexts/DragContext.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
src/contexts/FileSystemContext.tsx
Normal file
19
src/contexts/FileSystemContext.tsx
Normal 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
|
||||
}
|
||||
256
src/hooks/__tests__/useClickOutside.test.ts
Normal file
256
src/hooks/__tests__/useClickOutside.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
316
src/hooks/__tests__/useLocalStorageState.test.ts
Normal file
316
src/hooks/__tests__/useLocalStorageState.test.ts
Normal 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
22
src/hooks/domain/index.ts
Normal 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'
|
||||
14
src/hooks/domain/useAutoLoadTabContent.ts
Normal file
14
src/hooks/domain/useAutoLoadTabContent.ts
Normal 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])
|
||||
}
|
||||
45
src/hooks/domain/useDialogState.ts
Normal file
45
src/hooks/domain/useDialogState.ts
Normal 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 }
|
||||
}
|
||||
162
src/hooks/domain/useDragDrop.ts
Normal file
162
src/hooks/domain/useDragDrop.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
144
src/hooks/domain/useExport.ts
Normal file
144
src/hooks/domain/useExport.ts
Normal 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
|
||||
}
|
||||
}
|
||||
105
src/hooks/domain/useFileAction.ts
Normal file
105
src/hooks/domain/useFileAction.ts
Normal 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
|
||||
}
|
||||
}
|
||||
71
src/hooks/domain/useFileImport.ts
Normal file
71
src/hooks/domain/useFileImport.ts
Normal 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
|
||||
}
|
||||
}
|
||||
236
src/hooks/domain/useFileSystemController.ts
Normal file
236
src/hooks/domain/useFileSystemController.ts
Normal 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
|
||||
}
|
||||
}
|
||||
177
src/hooks/domain/useFileTabs.ts
Normal file
177
src/hooks/domain/useFileTabs.ts
Normal 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
|
||||
}
|
||||
}
|
||||
49
src/hooks/domain/useFileTree.ts
Normal file
49
src/hooks/domain/useFileTree.ts
Normal 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 }
|
||||
}
|
||||
36
src/hooks/domain/useHTMLImport.ts
Normal file
36
src/hooks/domain/useHTMLImport.ts
Normal 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
|
||||
}
|
||||
}
|
||||
57
src/hooks/domain/useImageAutoDelete.ts
Normal file
57
src/hooks/domain/useImageAutoDelete.ts
Normal 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])
|
||||
}
|
||||
249
src/hooks/domain/useMarkdownLogic.ts
Normal file
249
src/hooks/domain/useMarkdownLogic.ts
Normal 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)
|
||||
}, []) // 空依赖数组以防止重新挂载
|
||||
}
|
||||
122
src/hooks/domain/useNoteBrowser.ts
Normal file
122
src/hooks/domain/useNoteBrowser.ts
Normal 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
|
||||
}
|
||||
}
|
||||
134
src/hooks/domain/useNoteContent.ts
Normal file
134
src/hooks/domain/useNoteContent.ts
Normal 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
|
||||
}
|
||||
}
|
||||
23
src/hooks/domain/usePDFImport.ts
Normal file
23
src/hooks/domain/usePDFImport.ts
Normal 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
|
||||
}
|
||||
}
|
||||
50
src/hooks/domain/useUnsavedChangesHandler.ts
Normal file
50
src/hooks/domain/useUnsavedChangesHandler.ts
Normal 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
|
||||
}
|
||||
}
|
||||
1
src/hooks/events/index.ts
Normal file
1
src/hooks/events/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useNotebookEvents } from './useNotebookEvents'
|
||||
77
src/hooks/events/useNotebookEvents.ts
Normal file
77
src/hooks/events/useNotebookEvents.ts
Normal 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
6
src/hooks/index.ts
Normal 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
4
src/hooks/ui/index.ts
Normal 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'
|
||||
54
src/hooks/ui/useKeyboardShortcuts.ts
Normal file
54
src/hooks/ui/useKeyboardShortcuts.ts
Normal 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])
|
||||
}
|
||||
33
src/hooks/ui/useSidebarResize.ts
Normal file
33
src/hooks/ui/useSidebarResize.ts
Normal 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 }
|
||||
}
|
||||
|
||||
22
src/hooks/ui/useSidebarState.ts
Normal file
22
src/hooks/ui/useSidebarState.ts
Normal 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
|
||||
}
|
||||
}
|
||||
60
src/hooks/ui/useTOCState.ts
Normal file
60
src/hooks/ui/useTOCState.ts
Normal 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
2
src/hooks/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { useLocalStorageState } from './useLocalStorageState'
|
||||
export { useClickOutside } from './useClickOutside'
|
||||
47
src/hooks/utils/useClickOutside.ts
Normal file
47
src/hooks/utils/useClickOutside.ts
Normal 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])
|
||||
}
|
||||
36
src/hooks/utils/useLocalStorageState.ts
Normal file
36
src/hooks/utils/useLocalStorageState.ts
Normal 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
142
src/index.css
Normal 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
122
src/lib/api/client.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
104
src/lib/api/createModuleApi.ts
Normal file
104
src/lib/api/createModuleApi.ts
Normal 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
71
src/lib/api/http.ts
Normal 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
20
src/lib/api/index.ts
Normal 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'
|
||||
35
src/lib/api/modules/ModuleApiRegistry.ts
Normal file
35
src/lib/api/modules/ModuleApiRegistry.ts
Normal 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()
|
||||
3
src/lib/api/modules/index.ts
Normal file
3
src/lib/api/modules/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './types'
|
||||
export { ModuleApiInstance, createModuleApi } from '../createModuleApi'
|
||||
export * from './ModuleApiRegistry'
|
||||
28
src/lib/api/modules/types.ts
Normal file
28
src/lib/api/modules/types.ts
Normal 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
Reference in New Issue
Block a user