mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-04 20:00:00 +08:00
Fix/newNode (#9727)
* feat: enhance note saving functionality with immediate cache invalidation * fix: improve file name handling and localization updates * feat: implement multi-level note sorting and enhance state management - Introduced sorting options for notes by name, update time, and creation time, allowing users to sort notes in ascending and descending order. - Updated NotesPage and NotesSidebar components to handle sorting functionality. - Enhanced Redux store to manage the sorting type, improving state management for note organization. - Refactored related services to support recursive sorting logic, ensuring a consistent user experience. * feat(i18n): add new file upload messages for multiple languages * fix(styles): adjust padding in richtext.scss to accommodate scrollbar * style(NotesSidebar): add border-top-left-radius to enhance sidebar aesthetics * feat(RichEditPopup): add isFullWidth prop to enhance popup layout * feat(RichEditPopup): disable keyboard interaction for improved user experience * feat(NotesPage): integrate sorting after node deletion and movement - Added sorting functionality to be triggered after deleting or moving nodes, ensuring notes are organized immediately. - Updated dependencies in useCallback hooks to include sortType for consistent behavior across operations. * feat(NotesService): update initWorkSpace and sortAllLevels to accept sortType - Modified initWorkSpace to include sortType for improved note organization during initialization. - Enhanced sortAllLevels to optionally accept a tree parameter, allowing for more flexible sorting operations. - Updated NotesPage to utilize the new parameters, ensuring consistent sorting behavior across various actions. * feat(NotesSidebar): implement in-place editing for note renaming - Introduced a new hook, useInPlaceEdit, to manage in-place editing of note names, enhancing user experience during renaming. - Updated the NotesSidebar component to utilize this hook, streamlining the editing process and improving state management. - Removed redundant state variables related to editing, simplifying the component's logic. * refactor(NotesPage): remove commented code for clarity - Removed a comment regarding folder selection behavior to streamline the code and improve readability. - This change does not affect functionality but enhances the overall code quality. * feat(NotesSettings): update initWorkSpace to include default sort type - Modified initWorkSpace calls in NotesSettings to accept a default sort type of 'sort_a2z', ensuring consistent note organization during path updates and resets. - This change enhances the initialization process by applying a predefined sorting method.
This commit is contained in:
parent
bf23c5b209
commit
ce4cad67a6
@ -311,8 +311,7 @@ export async function scanDir(dirPath: string, depth = 0, basePath?: string): Pr
|
|||||||
*/
|
*/
|
||||||
export function getName(baseDir: string, fileName: string, isFile: boolean): string {
|
export function getName(baseDir: string, fileName: string, isFile: boolean): string {
|
||||||
// 首先清理文件名
|
// 首先清理文件名
|
||||||
const sanitizedName = sanitizeFilename(fileName)
|
const baseName = sanitizeFilename(fileName)
|
||||||
const baseName = sanitizedName.replace(/\d+$/, '')
|
|
||||||
let candidate = isFile ? baseName + '.md' : baseName
|
let candidate = isFile ? baseName + '.md' : baseName
|
||||||
let counter = 1
|
let counter = 1
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
.tiptap {
|
.tiptap {
|
||||||
padding: 12px 60px;
|
// 预留5px给scrollbar
|
||||||
|
padding: 12px 55px 12px 60px;
|
||||||
outline: none;
|
outline: none;
|
||||||
min-height: 120px;
|
min-height: 120px;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
|
|||||||
@ -97,6 +97,7 @@ const PopupContainer: React.FC<Props> = ({
|
|||||||
afterClose={onClose}
|
afterClose={onClose}
|
||||||
afterOpenChange={handleAfterOpenChange}
|
afterOpenChange={handleAfterOpenChange}
|
||||||
maskClosable={false}
|
maskClosable={false}
|
||||||
|
keyboard={false}
|
||||||
centered>
|
centered>
|
||||||
<EditorContainer>
|
<EditorContainer>
|
||||||
<RichEditor
|
<RichEditor
|
||||||
@ -108,6 +109,7 @@ const PopupContainer: React.FC<Props> = ({
|
|||||||
onCommandsReady={handleCommandsReady}
|
onCommandsReady={handleCommandsReady}
|
||||||
minHeight={300}
|
minHeight={300}
|
||||||
maxHeight={500}
|
maxHeight={500}
|
||||||
|
isFullWidth={true}
|
||||||
className="rich-edit-popup-editor"
|
className="rich-edit-popup-editor"
|
||||||
/>
|
/>
|
||||||
</EditorContainer>
|
</EditorContainer>
|
||||||
|
|||||||
@ -1615,9 +1615,12 @@
|
|||||||
"new_folder": "New Folder",
|
"new_folder": "New Folder",
|
||||||
"new_note": "Create a new note",
|
"new_note": "Create a new note",
|
||||||
"no_content_to_copy": "No content to copy",
|
"no_content_to_copy": "No content to copy",
|
||||||
|
"no_file_selected": "Please select the file to upload",
|
||||||
"only_markdown": "Only Markdown files are supported",
|
"only_markdown": "Only Markdown files are supported",
|
||||||
|
"only_one_file_allowed": "Only one file can be uploaded",
|
||||||
"open_folder": "Open an external folder",
|
"open_folder": "Open an external folder",
|
||||||
"rename": "Rename",
|
"rename": "Rename",
|
||||||
|
"rename_changed": "Due to security policies, the filename has been changed from {{original}} to {{final}}",
|
||||||
"save": "Save to Notes",
|
"save": "Save to Notes",
|
||||||
"settings": {
|
"settings": {
|
||||||
"data": {
|
"data": {
|
||||||
|
|||||||
@ -1615,9 +1615,12 @@
|
|||||||
"new_folder": "新しいフォルダーを作成する",
|
"new_folder": "新しいフォルダーを作成する",
|
||||||
"new_note": "新規ノート作成",
|
"new_note": "新規ノート作成",
|
||||||
"no_content_to_copy": "コピーするコンテンツはありません",
|
"no_content_to_copy": "コピーするコンテンツはありません",
|
||||||
|
"no_file_selected": "アップロードするファイルを選択してください",
|
||||||
"only_markdown": "Markdown ファイルのみをアップロードできます",
|
"only_markdown": "Markdown ファイルのみをアップロードできます",
|
||||||
|
"only_one_file_allowed": "アップロードできるファイルは1つだけです",
|
||||||
"open_folder": "外部フォルダーを開きます",
|
"open_folder": "外部フォルダーを開きます",
|
||||||
"rename": "名前の変更",
|
"rename": "名前の変更",
|
||||||
|
"rename_changed": "セキュリティポリシーにより、ファイル名は{{original}}から{{final}}に変更されました",
|
||||||
"save": "メモに保存する",
|
"save": "メモに保存する",
|
||||||
"settings": {
|
"settings": {
|
||||||
"data": {
|
"data": {
|
||||||
|
|||||||
@ -1615,9 +1615,12 @@
|
|||||||
"new_folder": "Новая папка",
|
"new_folder": "Новая папка",
|
||||||
"new_note": "Создать заметку",
|
"new_note": "Создать заметку",
|
||||||
"no_content_to_copy": "Нет контента для копирования",
|
"no_content_to_copy": "Нет контента для копирования",
|
||||||
|
"no_file_selected": "Пожалуйста, выберите файл для загрузки",
|
||||||
"only_markdown": "Только Markdown",
|
"only_markdown": "Только Markdown",
|
||||||
|
"only_one_file_allowed": "Можно загрузить только один файл",
|
||||||
"open_folder": "Откройте внешнюю папку",
|
"open_folder": "Откройте внешнюю папку",
|
||||||
"rename": "переименовать",
|
"rename": "переименовать",
|
||||||
|
"rename_changed": "В связи с политикой безопасности имя файла было изменено с {{Original}} на {{final}}",
|
||||||
"save": "Сохранить в заметки",
|
"save": "Сохранить в заметки",
|
||||||
"settings": {
|
"settings": {
|
||||||
"data": {
|
"data": {
|
||||||
|
|||||||
@ -1615,9 +1615,12 @@
|
|||||||
"new_folder": "新建文件夹",
|
"new_folder": "新建文件夹",
|
||||||
"new_note": "新建笔记",
|
"new_note": "新建笔记",
|
||||||
"no_content_to_copy": "没有内容可复制",
|
"no_content_to_copy": "没有内容可复制",
|
||||||
|
"no_file_selected": "请选择要上传的文件",
|
||||||
"only_markdown": "仅支持 Markdown 格式",
|
"only_markdown": "仅支持 Markdown 格式",
|
||||||
|
"only_one_file_allowed": "只能上传一个文件",
|
||||||
"open_folder": "打开外部文件夹",
|
"open_folder": "打开外部文件夹",
|
||||||
"rename": "重命名",
|
"rename": "重命名",
|
||||||
|
"rename_changed": "由于安全策略,文件名已从 {{original}} 更改为 {{final}}",
|
||||||
"save": "保存到笔记",
|
"save": "保存到笔记",
|
||||||
"settings": {
|
"settings": {
|
||||||
"data": {
|
"data": {
|
||||||
|
|||||||
@ -1615,9 +1615,12 @@
|
|||||||
"new_folder": "新建文件夾",
|
"new_folder": "新建文件夾",
|
||||||
"new_note": "新建筆記",
|
"new_note": "新建筆記",
|
||||||
"no_content_to_copy": "沒有內容可複制",
|
"no_content_to_copy": "沒有內容可複制",
|
||||||
|
"no_file_selected": "請選擇要上傳的文件",
|
||||||
"only_markdown": "僅支援 Markdown 格式",
|
"only_markdown": "僅支援 Markdown 格式",
|
||||||
|
"only_one_file_allowed": "只能上傳一個文件",
|
||||||
"open_folder": "打開外部文件夾",
|
"open_folder": "打開外部文件夾",
|
||||||
"rename": "重命名",
|
"rename": "重命名",
|
||||||
|
"rename_changed": "由於安全策略,文件名已從 {{original}} 更改為 {{final}}",
|
||||||
"save": "儲存到筆記",
|
"save": "儲存到筆記",
|
||||||
"settings": {
|
"settings": {
|
||||||
"data": {
|
"data": {
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import {
|
|||||||
} from '@renderer/services/NotesService'
|
} from '@renderer/services/NotesService'
|
||||||
import { getNotesTree, isParentNode, updateNodeInTree } from '@renderer/services/NotesTreeService'
|
import { getNotesTree, isParentNode, updateNodeInTree } from '@renderer/services/NotesTreeService'
|
||||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||||
import { selectActiveFilePath, setActiveFilePath } from '@renderer/store/note'
|
import { selectActiveFilePath, selectSortType, setActiveFilePath, setSortType } from '@renderer/store/note'
|
||||||
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
||||||
import { FileChangeEvent } from '@shared/config/types'
|
import { FileChangeEvent } from '@shared/config/types'
|
||||||
import { useLiveQuery } from 'dexie-react-hooks'
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
@ -37,6 +37,7 @@ const NotesPage: FC = () => {
|
|||||||
const { showWorkspace } = useSettings()
|
const { showWorkspace } = useSettings()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const activeFilePath = useAppSelector(selectActiveFilePath)
|
const activeFilePath = useAppSelector(selectActiveFilePath)
|
||||||
|
const sortType = useAppSelector(selectSortType)
|
||||||
const { settings, notesPath, updateNotesPath } = useNotesSettings()
|
const { settings, notesPath, updateNotesPath } = useNotesSettings()
|
||||||
|
|
||||||
// 混合策略:useLiveQuery用于笔记树,React Query用于文件内容
|
// 混合策略:useLiveQuery用于笔记树,React Query用于文件内容
|
||||||
@ -53,6 +54,8 @@ const NotesPage: FC = () => {
|
|||||||
const isEditorInitialized = useRef(false)
|
const isEditorInitialized = useRef(false)
|
||||||
const lastContentRef = useRef<string>('')
|
const lastContentRef = useRef<string>('')
|
||||||
const isInitialSortApplied = useRef(false)
|
const isInitialSortApplied = useRef(false)
|
||||||
|
const isRenamingRef = useRef(false)
|
||||||
|
const isCreatingNoteRef = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateCharCount = () => {
|
const updateCharCount = () => {
|
||||||
@ -131,7 +134,7 @@ const NotesPage: FC = () => {
|
|||||||
async function applyInitialSort() {
|
async function applyInitialSort() {
|
||||||
if (notesTree.length > 0 && !isInitialSortApplied.current) {
|
if (notesTree.length > 0 && !isInitialSortApplied.current) {
|
||||||
try {
|
try {
|
||||||
await sortAllLevels('sort_a2z')
|
await sortAllLevels(sortType)
|
||||||
isInitialSortApplied.current = true
|
isInitialSortApplied.current = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to apply initial sorting:', error as Error)
|
logger.error('Failed to apply initial sorting:', error as Error)
|
||||||
@ -140,14 +143,21 @@ const NotesPage: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applyInitialSort()
|
applyInitialSort()
|
||||||
}, [notesTree.length])
|
}, [notesTree.length, sortType])
|
||||||
|
|
||||||
// 处理树同步时的状态管理
|
// 处理树同步时的状态管理
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (notesTree.length === 0) return
|
if (notesTree.length === 0) return
|
||||||
|
|
||||||
// 如果有activeFilePath但找不到对应节点,清空选择
|
// 如果有activeFilePath但找不到对应节点,清空选择
|
||||||
if (activeFilePath && !activeNode) {
|
// 但要排除正在同步树结构、重命名或创建笔记的情况,避免在这些操作中误清空
|
||||||
|
if (
|
||||||
|
activeFilePath &&
|
||||||
|
!activeNode &&
|
||||||
|
!isSyncingTreeRef.current &&
|
||||||
|
!isRenamingRef.current &&
|
||||||
|
!isCreatingNoteRef.current
|
||||||
|
) {
|
||||||
dispatch(setActiveFilePath(undefined))
|
dispatch(setActiveFilePath(undefined))
|
||||||
}
|
}
|
||||||
}, [notesTree, activeFilePath, activeNode, dispatch])
|
}, [notesTree, activeFilePath, activeNode, dispatch])
|
||||||
@ -191,7 +201,7 @@ const NotesPage: FC = () => {
|
|||||||
invalidateFileContent(filePath)
|
invalidateFileContent(filePath)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await initWorkSpace(notesPath)
|
await initWorkSpace(notesPath, sortType)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@ -210,7 +220,7 @@ const NotesPage: FC = () => {
|
|||||||
|
|
||||||
// 重新同步数据库,useLiveQuery会自动响应数据库变化
|
// 重新同步数据库,useLiveQuery会自动响应数据库变化
|
||||||
try {
|
try {
|
||||||
await initWorkSpace(notesPath)
|
await initWorkSpace(notesPath, sortType)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to sync database:', error as Error)
|
logger.error('Failed to sync database:', error as Error)
|
||||||
} finally {
|
} finally {
|
||||||
@ -264,7 +274,8 @@ const NotesPage: FC = () => {
|
|||||||
dispatch,
|
dispatch,
|
||||||
currentContent,
|
currentContent,
|
||||||
debouncedSave,
|
debouncedSave,
|
||||||
saveCurrentNote
|
saveCurrentNote,
|
||||||
|
sortType
|
||||||
])
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -273,7 +284,7 @@ const NotesPage: FC = () => {
|
|||||||
// 标记编辑器已初始化
|
// 标记编辑器已初始化
|
||||||
isEditorInitialized.current = true
|
isEditorInitialized.current = true
|
||||||
}
|
}
|
||||||
}, [currentContent])
|
}, [currentContent, activeFilePath])
|
||||||
|
|
||||||
// 切换文件时重置编辑器初始化状态并兜底保存
|
// 切换文件时重置编辑器初始化状态并兜底保存
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -319,17 +330,27 @@ const NotesPage: FC = () => {
|
|||||||
const handleCreateNote = useCallback(
|
const handleCreateNote = useCallback(
|
||||||
async (name: string) => {
|
async (name: string) => {
|
||||||
try {
|
try {
|
||||||
|
isCreatingNoteRef.current = true
|
||||||
|
|
||||||
const targetPath = getTargetFolderPath()
|
const targetPath = getTargetFolderPath()
|
||||||
if (!targetPath) {
|
if (!targetPath) {
|
||||||
throw new Error('No folder path selected')
|
throw new Error('No folder path selected')
|
||||||
}
|
}
|
||||||
const newNote = await createNote(name, '', targetPath)
|
const newNote = await createNote(name, '', targetPath)
|
||||||
dispatch(setActiveFilePath(newNote.externalPath))
|
dispatch(setActiveFilePath(newNote.externalPath))
|
||||||
|
setSelectedFolderId(null)
|
||||||
|
|
||||||
|
await sortAllLevels(sortType)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to create note:', error as Error)
|
logger.error('Failed to create note:', error as Error)
|
||||||
|
} finally {
|
||||||
|
// 延迟重置标志,给数据库同步一些时间
|
||||||
|
setTimeout(() => {
|
||||||
|
isCreatingNoteRef.current = false
|
||||||
|
}, 500)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dispatch, getTargetFolderPath]
|
[dispatch, getTargetFolderPath, sortType]
|
||||||
)
|
)
|
||||||
|
|
||||||
// 切换展开状态
|
// 切换展开状态
|
||||||
@ -410,10 +431,7 @@ const NotesPage: FC = () => {
|
|||||||
logger.error('Failed to load note:', error as Error)
|
logger.error('Failed to load note:', error as Error)
|
||||||
}
|
}
|
||||||
} else if (node.type === 'folder') {
|
} else if (node.type === 'folder') {
|
||||||
// 设置选中的文件夹,同时清除活动文件
|
|
||||||
setSelectedFolderId(node.id)
|
setSelectedFolderId(node.id)
|
||||||
// 清除活动文件状态,这样文件的高亮会被清除
|
|
||||||
dispatch(setActiveFilePath(undefined))
|
|
||||||
await handleToggleExpanded(node.id)
|
await handleToggleExpanded(node.id)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -432,6 +450,7 @@ const NotesPage: FC = () => {
|
|||||||
(nodeToDelete.externalPath === activeFilePath || isParentNode(notesTree, nodeId, activeNode?.id || ''))
|
(nodeToDelete.externalPath === activeFilePath || isParentNode(notesTree, nodeId, activeNode?.id || ''))
|
||||||
|
|
||||||
await deleteNode(nodeId)
|
await deleteNode(nodeId)
|
||||||
|
await sortAllLevels(sortType)
|
||||||
|
|
||||||
// 如果删除的是当前活动节点或其父节点,清空编辑器
|
// 如果删除的是当前活动节点或其父节点,清空编辑器
|
||||||
if (isActiveNodeOrParent) {
|
if (isActiveNodeOrParent) {
|
||||||
@ -444,24 +463,47 @@ const NotesPage: FC = () => {
|
|||||||
logger.error('Failed to delete node:', error as Error)
|
logger.error('Failed to delete node:', error as Error)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeFilePath, activeNode, notesTree, dispatch, findNodeById]
|
[findNodeById, notesTree, activeFilePath, activeNode?.id, sortType, dispatch]
|
||||||
)
|
)
|
||||||
|
|
||||||
// 重命名节点
|
// 重命名节点
|
||||||
const handleRenameNode = useCallback(
|
const handleRenameNode = useCallback(
|
||||||
async (nodeId: string, newName: string) => {
|
async (nodeId: string, newName: string) => {
|
||||||
try {
|
try {
|
||||||
|
isRenamingRef.current = true
|
||||||
|
|
||||||
const tree = await getNotesTree()
|
const tree = await getNotesTree()
|
||||||
const node = findNodeById(tree, nodeId)
|
const node = findNodeById(tree, nodeId)
|
||||||
|
|
||||||
if (node && node.name !== newName) {
|
if (node && node.name !== newName) {
|
||||||
await renameNode(nodeId, newName)
|
const oldExternalPath = node.externalPath
|
||||||
|
const renamedNode = await renameNode(nodeId, newName)
|
||||||
|
|
||||||
|
if (renamedNode.type === 'file' && activeFilePath === oldExternalPath) {
|
||||||
|
dispatch(setActiveFilePath(renamedNode.externalPath))
|
||||||
|
} else if (
|
||||||
|
renamedNode.type === 'folder' &&
|
||||||
|
activeFilePath &&
|
||||||
|
activeFilePath.startsWith(oldExternalPath + '/')
|
||||||
|
) {
|
||||||
|
const relativePath = activeFilePath.substring(oldExternalPath.length)
|
||||||
|
const newFilePath = renamedNode.externalPath + relativePath
|
||||||
|
dispatch(setActiveFilePath(newFilePath))
|
||||||
|
}
|
||||||
|
await sortAllLevels(sortType)
|
||||||
|
if (renamedNode.name !== newName) {
|
||||||
|
window.message.info(t('notes.rename_changed', { original: newName, final: renamedNode.name }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to rename node:', error as Error)
|
logger.error('Failed to rename node:', error as Error)
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => {
|
||||||
|
isRenamingRef.current = false
|
||||||
|
}, 500)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[findNodeById]
|
[activeFilePath, dispatch, findNodeById, sortType, t]
|
||||||
)
|
)
|
||||||
|
|
||||||
// 处理文件上传
|
// 处理文件上传
|
||||||
@ -507,22 +549,28 @@ const NotesPage: FC = () => {
|
|||||||
async (sourceNodeId: string, targetNodeId: string, position: 'before' | 'after' | 'inside') => {
|
async (sourceNodeId: string, targetNodeId: string, position: 'before' | 'after' | 'inside') => {
|
||||||
try {
|
try {
|
||||||
await moveNode(sourceNodeId, targetNodeId, position)
|
await moveNode(sourceNodeId, targetNodeId, position)
|
||||||
|
await sortAllLevels(sortType)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to move nodes:', error as Error)
|
logger.error('Failed to move nodes:', error as Error)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[]
|
[sortType]
|
||||||
)
|
)
|
||||||
|
|
||||||
// 处理节点排序
|
// 处理节点排序
|
||||||
const handleSortNodes = useCallback(async (sortType: NotesSortType) => {
|
const handleSortNodes = useCallback(
|
||||||
try {
|
async (newSortType: NotesSortType) => {
|
||||||
await sortAllLevels(sortType)
|
try {
|
||||||
} catch (error) {
|
// 更新Redux中的排序类型
|
||||||
logger.error('Failed to sort notes:', error as Error)
|
dispatch(setSortType(newSortType))
|
||||||
throw error
|
await sortAllLevels(newSortType)
|
||||||
}
|
} catch (error) {
|
||||||
}, [])
|
logger.error('Failed to sort notes:', error as Error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
)
|
||||||
|
|
||||||
const getCurrentNoteContent = useCallback(() => {
|
const getCurrentNoteContent = useCallback(() => {
|
||||||
if (settings.defaultEditMode === 'source') {
|
if (settings.defaultEditMode === 'source') {
|
||||||
|
|||||||
@ -2,11 +2,14 @@ import { loggerService } from '@logger'
|
|||||||
import { DeleteIcon } from '@renderer/components/Icons'
|
import { DeleteIcon } from '@renderer/components/Icons'
|
||||||
import SaveToKnowledgePopup from '@renderer/components/Popups/SaveToKnowledgePopup'
|
import SaveToKnowledgePopup from '@renderer/components/Popups/SaveToKnowledgePopup'
|
||||||
import Scrollbar from '@renderer/components/Scrollbar'
|
import Scrollbar from '@renderer/components/Scrollbar'
|
||||||
|
import { useInPlaceEdit } from '@renderer/hooks/useInPlaceEdit'
|
||||||
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
|
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
|
||||||
import { useActiveNode } from '@renderer/hooks/useNotesQuery'
|
import { useActiveNode } from '@renderer/hooks/useNotesQuery'
|
||||||
import NotesSidebarHeader from '@renderer/pages/notes/NotesSidebarHeader'
|
import NotesSidebarHeader from '@renderer/pages/notes/NotesSidebarHeader'
|
||||||
|
import { useAppSelector } from '@renderer/store'
|
||||||
|
import { selectSortType } from '@renderer/store/note'
|
||||||
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
||||||
import { Dropdown, Input, MenuProps } from 'antd'
|
import { Dropdown, Input, InputRef, MenuProps } from 'antd'
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@ -19,7 +22,7 @@ import {
|
|||||||
Star,
|
Star,
|
||||||
StarOff
|
StarOff
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { FC, useCallback, useMemo, useRef, useState } from 'react'
|
import { FC, Ref, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
@ -57,8 +60,8 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { bases } = useKnowledgeBases()
|
const { bases } = useKnowledgeBases()
|
||||||
const { activeNode } = useActiveNode(notesTree)
|
const { activeNode } = useActiveNode(notesTree)
|
||||||
|
const sortType = useAppSelector(selectSortType)
|
||||||
const [editingNodeId, setEditingNodeId] = useState<string | null>(null)
|
const [editingNodeId, setEditingNodeId] = useState<string | null>(null)
|
||||||
const [editingName, setEditingName] = useState('')
|
|
||||||
const [draggedNodeId, setDraggedNodeId] = useState<string | null>(null)
|
const [draggedNodeId, setDraggedNodeId] = useState<string | null>(null)
|
||||||
const [dragOverNodeId, setDragOverNodeId] = useState<string | null>(null)
|
const [dragOverNodeId, setDragOverNodeId] = useState<string | null>(null)
|
||||||
const [dragPosition, setDragPosition] = useState<'before' | 'inside' | 'after'>('inside')
|
const [dragPosition, setDragPosition] = useState<'before' | 'inside' | 'after'>('inside')
|
||||||
@ -66,8 +69,56 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
const [isShowSearch, setIsShowSearch] = useState(false)
|
const [isShowSearch, setIsShowSearch] = useState(false)
|
||||||
const [searchKeyword, setSearchKeyword] = useState('')
|
const [searchKeyword, setSearchKeyword] = useState('')
|
||||||
const [isDragOverSidebar, setIsDragOverSidebar] = useState(false)
|
const [isDragOverSidebar, setIsDragOverSidebar] = useState(false)
|
||||||
const [sortType, setSortType] = useState<NotesSortType>('sort_a2z')
|
|
||||||
const dragNodeRef = useRef<HTMLDivElement | null>(null)
|
const dragNodeRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const scrollbarRef = useRef<any>(null)
|
||||||
|
|
||||||
|
const inPlaceEdit = useInPlaceEdit({
|
||||||
|
onSave: (newName: string) => {
|
||||||
|
if (editingNodeId && newName) {
|
||||||
|
onRenameNode(editingNodeId, newName)
|
||||||
|
logger.debug(`Renamed node ${editingNodeId} to "${newName}"`)
|
||||||
|
}
|
||||||
|
setEditingNodeId(null)
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
setEditingNodeId(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 滚动到活动节点
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeNode?.id && !isShowStarred && !isShowSearch && scrollbarRef.current) {
|
||||||
|
// 延迟一下确保DOM已更新
|
||||||
|
setTimeout(() => {
|
||||||
|
const scrollContainer = scrollbarRef.current as HTMLElement
|
||||||
|
if (scrollContainer) {
|
||||||
|
const activeElement = scrollContainer.querySelector(`[data-node-id="${activeNode.id}"]`) as HTMLElement
|
||||||
|
if (activeElement) {
|
||||||
|
// 获取元素相对于滚动容器的位置
|
||||||
|
const containerHeight = scrollContainer.clientHeight
|
||||||
|
const elementOffsetTop = activeElement.offsetTop
|
||||||
|
const elementHeight = activeElement.offsetHeight
|
||||||
|
const currentScrollTop = scrollContainer.scrollTop
|
||||||
|
|
||||||
|
// 检查元素是否在可视区域内
|
||||||
|
const elementTop = elementOffsetTop
|
||||||
|
const elementBottom = elementOffsetTop + elementHeight
|
||||||
|
const viewTop = currentScrollTop
|
||||||
|
const viewBottom = currentScrollTop + containerHeight
|
||||||
|
|
||||||
|
// 如果元素不在可视区域内,滚动到中心位置
|
||||||
|
if (elementTop < viewTop || elementBottom > viewBottom) {
|
||||||
|
const targetScrollTop = elementOffsetTop - (containerHeight - elementHeight) / 2
|
||||||
|
scrollContainer.scrollTo({
|
||||||
|
top: Math.max(0, targetScrollTop),
|
||||||
|
behavior: 'smooth'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
}, [activeNode?.id, isShowStarred, isShowSearch])
|
||||||
|
|
||||||
const handleCreateFolder = useCallback(() => {
|
const handleCreateFolder = useCallback(() => {
|
||||||
onCreateFolder(t('notes.untitled_folder'))
|
onCreateFolder(t('notes.untitled_folder'))
|
||||||
@ -79,30 +130,18 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
|
|
||||||
const handleSelectSortType = useCallback(
|
const handleSelectSortType = useCallback(
|
||||||
(selectedSortType: NotesSortType) => {
|
(selectedSortType: NotesSortType) => {
|
||||||
setSortType(selectedSortType)
|
|
||||||
onSortNodes(selectedSortType)
|
onSortNodes(selectedSortType)
|
||||||
},
|
},
|
||||||
[onSortNodes]
|
[onSortNodes]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleStartEdit = useCallback((node: NotesTreeNode) => {
|
const handleStartEdit = useCallback(
|
||||||
setEditingNodeId(node.id)
|
(node: NotesTreeNode) => {
|
||||||
setEditingName(node.name)
|
setEditingNodeId(node.id)
|
||||||
}, [])
|
inPlaceEdit.startEdit(node.name)
|
||||||
|
},
|
||||||
const handleFinishEdit = useCallback(() => {
|
[inPlaceEdit]
|
||||||
if (editingNodeId && editingName.trim()) {
|
)
|
||||||
onRenameNode(editingNodeId, editingName.trim())
|
|
||||||
}
|
|
||||||
setEditingNodeId(null)
|
|
||||||
setEditingName('')
|
|
||||||
logger.debug(`Renamed node ${editingNodeId} to "${editingName.trim()}"`)
|
|
||||||
}, [editingNodeId, editingName, onRenameNode])
|
|
||||||
|
|
||||||
const handleCancelEdit = useCallback(() => {
|
|
||||||
setEditingNodeId(null)
|
|
||||||
setEditingName('')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleDeleteNode = useCallback(
|
const handleDeleteNode = useCallback(
|
||||||
(node: NotesTreeNode) => {
|
(node: NotesTreeNode) => {
|
||||||
@ -306,8 +345,10 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
|
|
||||||
const renderTreeNode = useCallback(
|
const renderTreeNode = useCallback(
|
||||||
(node: NotesTreeNode, depth: number = 0) => {
|
(node: NotesTreeNode, depth: number = 0) => {
|
||||||
const isActive = node.id === activeNode?.id || (node.type === 'folder' && node.id === selectedFolderId)
|
const isActive = selectedFolderId
|
||||||
const isEditing = editingNodeId === node.id
|
? node.type === 'folder' && node.id === selectedFolderId
|
||||||
|
: node.id === activeNode?.id
|
||||||
|
const isEditing = editingNodeId === node.id && inPlaceEdit.isEditing
|
||||||
const hasChildren = node.children && node.children.length > 0
|
const hasChildren = node.children && node.children.length > 0
|
||||||
const isDragging = draggedNodeId === node.id
|
const isDragging = draggedNodeId === node.id
|
||||||
const isDragOver = dragOverNodeId === node.id
|
const isDragOver = dragOverNodeId === node.id
|
||||||
@ -328,6 +369,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
isDragInside={isDragInside}
|
isDragInside={isDragInside}
|
||||||
isDragAfter={isDragAfter}
|
isDragAfter={isDragAfter}
|
||||||
draggable={!isEditing}
|
draggable={!isEditing}
|
||||||
|
data-node-id={node.id}
|
||||||
onDragStart={(e) => handleDragStart(e, node)}
|
onDragStart={(e) => handleDragStart(e, node)}
|
||||||
onDragOver={(e) => handleDragOver(e, node)}
|
onDragOver={(e) => handleDragOver(e, node)}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
@ -361,15 +403,13 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
|
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<EditInput
|
<EditInput
|
||||||
value={editingName}
|
ref={inPlaceEdit.inputRef as Ref<InputRef>}
|
||||||
onChange={(e) => setEditingName(e.target.value)}
|
value={inPlaceEdit.editValue}
|
||||||
onPressEnter={handleFinishEdit}
|
onChange={inPlaceEdit.handleInputChange}
|
||||||
onBlur={handleFinishEdit}
|
onPressEnter={inPlaceEdit.saveEdit}
|
||||||
onKeyDown={(e) => {
|
onBlur={inPlaceEdit.saveEdit}
|
||||||
if (e.key === 'Escape') {
|
onKeyDown={inPlaceEdit.handleKeyDown}
|
||||||
handleCancelEdit()
|
onClick={(e) => e.stopPropagation()}
|
||||||
}
|
|
||||||
}}
|
|
||||||
autoFocus
|
autoFocus
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
@ -388,24 +428,27 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
activeNode,
|
|
||||||
selectedFolderId,
|
selectedFolderId,
|
||||||
|
activeNode?.id,
|
||||||
editingNodeId,
|
editingNodeId,
|
||||||
editingName,
|
inPlaceEdit.isEditing,
|
||||||
|
inPlaceEdit.inputRef,
|
||||||
|
inPlaceEdit.editValue,
|
||||||
|
inPlaceEdit.handleInputChange,
|
||||||
|
inPlaceEdit.saveEdit,
|
||||||
|
inPlaceEdit.handleKeyDown,
|
||||||
draggedNodeId,
|
draggedNodeId,
|
||||||
dragOverNodeId,
|
dragOverNodeId,
|
||||||
dragPosition,
|
dragPosition,
|
||||||
onSelectNode,
|
getMenuItems,
|
||||||
onToggleExpanded,
|
handleDragLeave,
|
||||||
handleFinishEdit,
|
handleDragEnd,
|
||||||
handleCancelEdit,
|
t,
|
||||||
handleDragStart,
|
handleDragStart,
|
||||||
handleDragOver,
|
handleDragOver,
|
||||||
handleDragLeave,
|
|
||||||
handleDrop,
|
handleDrop,
|
||||||
handleDragEnd,
|
onSelectNode,
|
||||||
getMenuItems,
|
onToggleExpanded
|
||||||
t
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -451,7 +494,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<NotesTreeContainer>
|
<NotesTreeContainer>
|
||||||
<StyledScrollbar>
|
<StyledScrollbar ref={scrollbarRef}>
|
||||||
<TreeContent>
|
<TreeContent>
|
||||||
{filteredTree.map((node) => renderTreeNode(node))}
|
{filteredTree.map((node) => renderTreeNode(node))}
|
||||||
{!isShowStarred && !isShowSearch && (
|
{!isShowStarred && !isShowSearch && (
|
||||||
@ -480,6 +523,7 @@ const SidebarContainer = styled.div`
|
|||||||
height: 100vh;
|
height: 100vh;
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
border-right: 1px solid var(--color-border);
|
border-right: 1px solid var(--color-border);
|
||||||
|
border-top-left-radius: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@ -70,7 +70,7 @@ const NotesSettings: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateNotesPath(tempPath)
|
updateNotesPath(tempPath)
|
||||||
initWorkSpace(tempPath)
|
initWorkSpace(tempPath, 'sort_a2z')
|
||||||
window.message.success(t('notes.settings.data.path_updated'))
|
window.message.success(t('notes.settings.data.path_updated'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to apply notes path:', error as Error)
|
logger.error('Failed to apply notes path:', error as Error)
|
||||||
@ -83,7 +83,7 @@ const NotesSettings: FC = () => {
|
|||||||
const info = await window.api.getAppInfo()
|
const info = await window.api.getAppInfo()
|
||||||
setTempPath(info.notesPath)
|
setTempPath(info.notesPath)
|
||||||
updateNotesPath(info.notesPath)
|
updateNotesPath(info.notesPath)
|
||||||
initWorkSpace(info.notesPath)
|
initWorkSpace(info.notesPath, 'sort_a2z')
|
||||||
window.message.success(t('notes.settings.data.reset_to_default'))
|
window.message.success(t('notes.settings.data.reset_to_default'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to reset to default:', error as Error)
|
logger.error('Failed to reset to default:', error as Error)
|
||||||
|
|||||||
@ -22,9 +22,9 @@ const logger = loggerService.withContext('NotesService')
|
|||||||
/**
|
/**
|
||||||
* 初始化/同步笔记树结构
|
* 初始化/同步笔记树结构
|
||||||
*/
|
*/
|
||||||
export async function initWorkSpace(folderPath: string): Promise<void> {
|
export async function initWorkSpace(folderPath: string, sortType: NotesSortType): Promise<void> {
|
||||||
const tree = await window.api.file.getDirectoryStructure(folderPath)
|
const tree = await window.api.file.getDirectoryStructure(folderPath)
|
||||||
await db.notes_tree.put({ id: NOTES_TREE_ID, tree })
|
await sortAllLevels(sortType, tree)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -326,9 +326,11 @@ function getSortFunction(sortType: NotesSortType): (a: NotesTreeNode, b: NotesTr
|
|||||||
/**
|
/**
|
||||||
* 递归排序笔记树中的所有层级
|
* 递归排序笔记树中的所有层级
|
||||||
*/
|
*/
|
||||||
export async function sortAllLevels(sortType: NotesSortType): Promise<void> {
|
export async function sortAllLevels(sortType: NotesSortType, tree?: NotesTreeNode[]): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const tree = await getNotesTree()
|
if (!tree) {
|
||||||
|
tree = await getNotesTree()
|
||||||
|
}
|
||||||
sortNodesArray(tree, sortType)
|
sortNodesArray(tree, sortType)
|
||||||
recursiveSortNodes(tree, sortType)
|
recursiveSortNodes(tree, sortType)
|
||||||
await db.notes_tree.put({ id: NOTES_TREE_ID, tree })
|
await db.notes_tree.put({ id: NOTES_TREE_ID, tree })
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||||
import { RootState } from '@renderer/store/index'
|
import { RootState } from '@renderer/store/index'
|
||||||
import { EditorView } from '@renderer/types'
|
import { EditorView } from '@renderer/types'
|
||||||
|
import { NotesSortType } from '@renderer/types/note'
|
||||||
|
|
||||||
export interface NotesSettings {
|
export interface NotesSettings {
|
||||||
isFullWidth: boolean
|
isFullWidth: boolean
|
||||||
@ -15,6 +16,7 @@ export interface NoteState {
|
|||||||
activeFilePath: string | undefined // 使用文件路径而不是nodeId
|
activeFilePath: string | undefined // 使用文件路径而不是nodeId
|
||||||
settings: NotesSettings
|
settings: NotesSettings
|
||||||
notesPath: string
|
notesPath: string
|
||||||
|
sortType: NotesSortType
|
||||||
}
|
}
|
||||||
|
|
||||||
export const initialState: NoteState = {
|
export const initialState: NoteState = {
|
||||||
@ -27,7 +29,8 @@ export const initialState: NoteState = {
|
|||||||
defaultEditMode: 'preview',
|
defaultEditMode: 'preview',
|
||||||
showTabStatus: true
|
showTabStatus: true
|
||||||
},
|
},
|
||||||
notesPath: ''
|
notesPath: '',
|
||||||
|
sortType: 'sort_a2z'
|
||||||
}
|
}
|
||||||
|
|
||||||
const noteSlice = createSlice({
|
const noteSlice = createSlice({
|
||||||
@ -45,15 +48,19 @@ const noteSlice = createSlice({
|
|||||||
},
|
},
|
||||||
setNotesPath: (state, action: PayloadAction<string>) => {
|
setNotesPath: (state, action: PayloadAction<string>) => {
|
||||||
state.notesPath = action.payload
|
state.notesPath = action.payload
|
||||||
|
},
|
||||||
|
setSortType: (state, action: PayloadAction<NotesSortType>) => {
|
||||||
|
state.sortType = action.payload
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const { setActiveNodeId, setActiveFilePath, updateNotesSettings, setNotesPath } = noteSlice.actions
|
export const { setActiveNodeId, setActiveFilePath, updateNotesSettings, setNotesPath, setSortType } = noteSlice.actions
|
||||||
|
|
||||||
export const selectActiveNodeId = (state: RootState) => state.note.activeNodeId
|
export const selectActiveNodeId = (state: RootState) => state.note.activeNodeId
|
||||||
export const selectActiveFilePath = (state: RootState) => state.note.activeFilePath
|
export const selectActiveFilePath = (state: RootState) => state.note.activeFilePath
|
||||||
export const selectNotesSettings = (state: RootState) => state.note.settings
|
export const selectNotesSettings = (state: RootState) => state.note.settings
|
||||||
export const selectNotesPath = (state: RootState) => state.note.notesPath
|
export const selectNotesPath = (state: RootState) => state.note.notesPath
|
||||||
|
export const selectSortType = (state: RootState) => state.note.sortType
|
||||||
|
|
||||||
export default noteSlice.reducer
|
export default noteSlice.reducer
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user