Initial commit
This commit is contained in:
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]
|
||||
}
|
||||
Reference in New Issue
Block a user