Initial commit

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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