From d4e2384f64e4733384af77b58c71aa5d867b6060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=87=AA=E7=94=B1=E7=9A=84=E4=B8=96=E7=95=8C=E4=BA=BA?= <3196812536@qq.com> Date: Fri, 18 Jul 2025 01:59:04 +0800 Subject: [PATCH] feat: add notes feature with sidebar, editor, and storage Introduces a full notes management feature, including a sidebar for folders and notes, a markdown editor using Vditor, and persistent storage of the notes tree. Adds new components (NotesNavbar, NotesSidebar), a NotesService utility for CRUD operations, and updates settings and migration logic to support workspace visibility. Also updates Chinese i18n for notes, and refines the notes type definition. --- package.json | 3 +- src/renderer/src/hooks/useStore.ts | 15 +- src/renderer/src/i18n/locales/zh-cn.json | 18 +- src/renderer/src/pages/notes/NotesNavbar.tsx | 99 ++++ src/renderer/src/pages/notes/NotesPage.tsx | 352 +++++++++++++- src/renderer/src/pages/notes/NotesSidebar.tsx | 443 ++++++++++++++++++ .../src/pages/notes/utils/NotesService.ts | 321 +++++++++++++ src/renderer/src/store/migrate.ts | 4 + src/renderer/src/store/settings.ts | 16 +- src/renderer/src/types/note.ts | 110 +---- yarn.lock | 17 + 11 files changed, 1297 insertions(+), 101 deletions(-) create mode 100644 src/renderer/src/pages/notes/NotesNavbar.tsx create mode 100644 src/renderer/src/pages/notes/NotesSidebar.tsx create mode 100644 src/renderer/src/pages/notes/utils/NotesService.ts diff --git a/package.json b/package.json index b8936e1b53..8129f01ddb 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,8 @@ "os-proxy-config": "^1.1.2", "pdfjs-dist": "4.10.38", "selection-hook": "^1.0.6", - "turndown": "7.2.0" + "turndown": "7.2.0", + "vditor": "^3.11.1" }, "devDependencies": { "@agentic/exa": "^7.3.3", diff --git a/src/renderer/src/hooks/useStore.ts b/src/renderer/src/hooks/useStore.ts index 1b731e74c7..3115813ed4 100644 --- a/src/renderer/src/hooks/useStore.ts +++ b/src/renderer/src/hooks/useStore.ts @@ -3,8 +3,10 @@ import { setAssistantsTabSortType, setShowAssistants, setShowTopics, + setShowWorkspace, toggleShowAssistants, - toggleShowTopics + toggleShowTopics, + toggleShowWorkspace } from '@renderer/store/settings' import { AssistantsSortType } from '@renderer/types' @@ -39,3 +41,14 @@ export function useAssistantsTabSortType() { setAssistantsTabSortType: (sortType: AssistantsSortType) => dispatch(setAssistantsTabSortType(sortType)) } } + +export function useShowWorkspace() { + const showWorkspace = useAppSelector((state) => state.settings.showWorkspace) + const dispatch = useAppDispatch() + + return { + showWorkspace, + setShowWorkspace: (show: boolean) => dispatch(setShowWorkspace(show)), + toggleShowWorkspace: () => dispatch(toggleShowWorkspace()) + } +} diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 1d1fe0242b..31aa2e52c4 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -2560,7 +2560,23 @@ "stored_memories": "已存储记忆" }, "notes": { - "title": "笔记" + "title": "笔记", + "empty": "暂无笔记", + "new_folder": "新建文件夹", + "new_note": "新建笔记", + "untitled_note": "无标题笔记", + "untitled_folder": "新文件夹", + "rename": "重命名", + "delete": "删除", + "star": "收藏", + "unstar": "取消收藏", + "expand": "展开", + "collapse": "收起", + "delete_confirm": "确定要删除这个{{type}}吗?", + "delete_folder_confirm": "确定要删除文件夹 \"{{name}}\" 及其所有内容吗?", + "delete_note_confirm": "确定要删除笔记 \"{{name}}\" 吗?", + "folder": "文件夹", + "content_placeholder": "请输入笔记内容..." } } } diff --git a/src/renderer/src/pages/notes/NotesNavbar.tsx b/src/renderer/src/pages/notes/NotesNavbar.tsx new file mode 100644 index 0000000000..2cf464c99c --- /dev/null +++ b/src/renderer/src/pages/notes/NotesNavbar.tsx @@ -0,0 +1,99 @@ +import { Navbar, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar' +import { HStack } from '@renderer/components/Layout' +import { isMac } from '@renderer/config/constant' +import { useFullscreen } from '@renderer/hooks/useFullscreen' +import { useShowWorkspace } from '@renderer/hooks/useStore' +import { Tooltip } from 'antd' +import { PanelLeftClose, PanelRightClose } from 'lucide-react' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +const NotesNavbar = () => { + const { t } = useTranslation() + const { showWorkspace, toggleShowWorkspace } = useShowWorkspace() + const isFullscreen = useFullscreen() + const [sidebarHideCooldown, setSidebarHideCooldown] = useState(false) + + const handleToggleShowWorkspace = useCallback(() => { + if (showWorkspace) { + toggleShowWorkspace() + setSidebarHideCooldown(true) + } else { + toggleShowWorkspace() + } + }, [showWorkspace, toggleShowWorkspace]) + + return ( + + {showWorkspace && ( + + + + + + + + )} + + + {!showWorkspace && !sidebarHideCooldown && ( + + + + + + )} + {!showWorkspace && sidebarHideCooldown && ( + + setSidebarHideCooldown(false)}> + + + + )} + + + + ) +} + +export const NavbarIcon = styled.div` + -webkit-app-region: none; + border-radius: 8px; + height: 30px; + padding: 0 7px; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + transition: all 0.2s ease-in-out; + cursor: pointer; + .iconfont { + font-size: 18px; + color: var(--color-icon); + &.icon-a-addchat { + font-size: 20px; + } + &.icon-a-darkmode { + font-size: 20px; + } + &.icon-appstore { + font-size: 20px; + } + } + .anticon { + color: var(--color-icon); + font-size: 16px; + } + &:hover { + background-color: var(--color-background-mute); + color: var(--color-icon-white); + } +` + +export default NotesNavbar diff --git a/src/renderer/src/pages/notes/NotesPage.tsx b/src/renderer/src/pages/notes/NotesPage.tsx index 075c1be69d..f0bc003bb1 100644 --- a/src/renderer/src/pages/notes/NotesPage.tsx +++ b/src/renderer/src/pages/notes/NotesPage.tsx @@ -1,16 +1,302 @@ -import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' -import { FC } from 'react' +import 'vditor/dist/index.css' + +import Scrollbar from '@renderer/components/Scrollbar' +import { useTheme } from '@renderer/context/ThemeProvider' +import { useSettings } from '@renderer/hooks/useSettings' +import NotesNavbar from '@renderer/pages/notes/NotesNavbar' +import { ThemeMode } from '@renderer/types' +import { NotesTreeNode } from '@renderer/types/note' +import { Empty } from 'antd' +import { FC, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' import styled from 'styled-components' +import Vditor from 'vditor' + +import NotesSidebar from './NotesSidebar' +import { NotesService } from './utils/NotesService' const NotesPage: FC = () => { + const editorRef = useRef(null) + const [vditor, setVditor] = useState(null) + const { theme } = useTheme() + const { t } = useTranslation() + const { showWorkspace } = useSettings() + const [notesTree, setNotesTree] = useState([]) + const [activeNodeId, setActiveNodeId] = useState(undefined) + const [isLoading, setIsLoading] = useState(false) + + useEffect(() => { + const loadNotesTree = async () => { + try { + const tree = await NotesService.getNotesTree() + setNotesTree(tree) + } catch (error) { + console.error('Failed to load notes tree:', error) + } + } + + loadNotesTree() + }, []) + + // 初始化编辑器 - 只有在选择笔记后才初始化 + useEffect(() => { + if (editorRef.current && !vditor && activeNodeId) { + const editor = new Vditor(editorRef.current, { + height: '100%', + mode: 'ir', + theme: theme === ThemeMode.dark ? 'dark' : 'classic', + toolbar: [ + 'headings', + 'bold', + 'italic', + 'strike', + 'link', + '|', + 'list', + 'ordered-list', + 'check', + 'outdent', + 'indent', + '|', + 'quote', + 'line', + 'code', + 'inline-code', + '|', + 'upload', + 'table', + '|', + 'undo', + 'redo', + '|', + 'fullscreen', + 'preview' + ], + placeholder: t('notes.content_placeholder'), + cache: { + enable: false + }, + after: () => { + setVditor(editor) + }, + input: (value) => { + // 自动保存当前笔记 + if (activeNodeId) { + saveCurrentNote(value) + } + } + }) + } + + return () => { + if (vditor) { + vditor.destroy() + setVditor(null) + } + } + }, [theme, activeNodeId, t]) + + // 监听主题变化,更新编辑器样式 + useEffect(() => { + if (vditor) { + vditor.setTheme( + theme === ThemeMode.dark ? 'dark' : 'classic', + theme === ThemeMode.dark ? 'dark' : 'classic', + theme === ThemeMode.dark ? 'dark' : 'classic' + ) + } + }, [theme, vditor]) + + // 自动保存笔记内容 + const saveCurrentNote = async (content: string) => { + if (!activeNodeId) return + + try { + const activeNode = findNodeById(notesTree, activeNodeId) + if (activeNode && activeNode.type === 'file') { + await NotesService.updateNote(activeNode, content) + } + } catch (error) { + console.error('Failed to save note:', error) + } + } + + // 在树中查找节点 + const findNodeById = (tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null => { + for (const node of tree) { + if (node.id === nodeId) { + return node + } + if (node.children) { + const found = findNodeById(node.children, nodeId) + if (found) return found + } + } + return null + } + + // 创建文件夹 + const handleCreateFolder = async (name: string, parentId?: string) => { + try { + setIsLoading(true) + await NotesService.createFolder(name, parentId) + const updatedTree = await NotesService.getNotesTree() + setNotesTree(updatedTree) + } catch (error) { + console.error('Failed to create folder:', error) + } finally { + setIsLoading(false) + } + } + + // 创建笔记 + const handleCreateNote = async (name: string, parentId?: string) => { + try { + setIsLoading(true) + const newNote = await NotesService.createNote(name, '', parentId) + const updatedTree = await NotesService.getNotesTree() + setNotesTree(updatedTree) + + // 自动选择新创建的笔记 + setActiveNodeId(newNote.id) + } catch (error) { + console.error('Failed to create note:', error) + } finally { + setIsLoading(false) + } + } + + // 选择节点 + const handleSelectNode = async (node: NotesTreeNode) => { + if (node.type === 'file') { + try { + setIsLoading(true) + setActiveNodeId(node.id) + + // 读取笔记内容 + const content = await NotesService.readNote(node) + + // 如果编辑器已初始化,则更新内容 + if (vditor) { + vditor.setValue(content) + } + } catch (error) { + console.error('Failed to load note:', error) + } finally { + setIsLoading(false) + } + } else if (node.type === 'folder') { + // 切换文件夹展开状态 + await handleToggleExpanded(node.id) + } + } + + // 删除节点 + const handleDeleteNode = async (nodeId: string) => { + try { + setIsLoading(true) + await NotesService.deleteNode(nodeId) + const updatedTree = await NotesService.getNotesTree() + setNotesTree(updatedTree) + + // 如果删除的是当前活动节点,清空编辑器 + if (nodeId === activeNodeId) { + setActiveNodeId(undefined) + if (vditor) { + vditor.destroy() + setVditor(null) + } + } + } catch (error) { + console.error('Failed to delete node:', error) + } finally { + setIsLoading(false) + } + } + + // 重命名节点 + const handleRenameNode = async (nodeId: string, newName: string) => { + try { + setIsLoading(true) + await NotesService.renameNode(nodeId, newName) + const updatedTree = await NotesService.getNotesTree() + setNotesTree(updatedTree) + } catch (error) { + console.error('Failed to rename node:', error) + } finally { + setIsLoading(false) + } + } + + // 切换收藏状态 + const handleToggleStarred = async (nodeId: string) => { + try { + await NotesService.toggleStarred(nodeId) + const updatedTree = await NotesService.getNotesTree() + setNotesTree(updatedTree) + } catch (error) { + console.error('Failed to toggle starred:', error) + } + } + + // 切换展开状态 + const handleToggleExpanded = async (nodeId: string) => { + try { + await NotesService.toggleNodeExpanded(nodeId) + const updatedTree = await NotesService.getNotesTree() + setNotesTree(updatedTree) + } catch (error) { + console.error('Failed to toggle expanded:', error) + } + } + + // 移动节点 + const handleMoveNode = async (nodeId: string, targetParentId?: string) => { + try { + setIsLoading(true) + await NotesService.moveNode(nodeId, targetParentId) + const updatedTree = await NotesService.getNotesTree() + setNotesTree(updatedTree) + } catch (error) { + console.error('Failed to move node:', error) + } finally { + setIsLoading(false) + } + } + return ( - - 笔记 - - -

笔记页面

-

这里是笔记功能的内容区域。

+ + + {showWorkspace && ( + + )} + {isLoading && ( + + {t('common.loading')} + + )} + + + {activeNodeId ? ( + + ) : ( + + + + )} +
) @@ -18,13 +304,59 @@ const NotesPage: FC = () => { const Container = styled.div` flex: 1; + display: flex; + flex-direction: column; ` const ContentContainer = styled.div` - height: calc(100vh - var(--navbar-height)); display: flex; + flex: 1; + flex-direction: row; + overflow: hidden; +` + +const LoadingOverlay = styled.div` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(var(--color-background-rgb), 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +` + +const LoadingText = styled.div` + color: var(--color-text-2); + font-size: 14px; +` + +const EditorWrapper = styled.div` + flex: 1; + display: flex; + overflow: hidden; +` + +const EditorContainer = styled.div` + flex: 1; + border-radius: 4px; + overflow: hidden; + + .vditor { + border: 1px solid var(--color-border); + border-radius: 4px; + height: 100%; + } +` + +const MainContent = styled(Scrollbar)` + padding: 15px 20px; + display: flex; + width: 100%; flex-direction: column; - padding: 20px; + padding-bottom: 50px; ` export default NotesPage diff --git a/src/renderer/src/pages/notes/NotesSidebar.tsx b/src/renderer/src/pages/notes/NotesSidebar.tsx new file mode 100644 index 0000000000..97fadd5b68 --- /dev/null +++ b/src/renderer/src/pages/notes/NotesSidebar.tsx @@ -0,0 +1,443 @@ +import Scrollbar from '@renderer/components/Scrollbar' +import { NotesTreeNode } from '@renderer/types/note' +import { Input, Tooltip } from 'antd' +import { + ChevronDown, + ChevronRight, + Edit3, + File, + FilePlus, + Folder, + FolderOpen, + FolderPlus, + Star, + Trash2 +} from 'lucide-react' +import { FC, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface NotesSidebarProps { + onCreateFolder: (name: string, parentId?: string) => void + onCreateNote: (name: string, parentId?: string) => void + onSelectNode: (node: NotesTreeNode) => void + onDeleteNode: (nodeId: string) => void + onRenameNode: (nodeId: string, newName: string) => void + onToggleStarred: (nodeId: string) => void + onToggleExpanded: (nodeId: string) => void + onMoveNode: (nodeId: string, targetParentId?: string) => void + activeNodeId?: string + notesTree: NotesTreeNode[] +} + +const NotesSidebar: FC = ({ + onCreateFolder, + onCreateNote, + onSelectNode, + onDeleteNode, + onRenameNode, + onToggleStarred, + onToggleExpanded, + onMoveNode, + activeNodeId, + notesTree +}) => { + const { t } = useTranslation() + const [editingNodeId, setEditingNodeId] = useState(null) + const [editingName, setEditingName] = useState('') + const [draggedNodeId, setDraggedNodeId] = useState(null) + const [dragOverNodeId, setDragOverNodeId] = useState(null) + + const handleCreateFolder = useCallback(() => { + onCreateFolder(t('notes.untitled_folder')) + }, [onCreateFolder, t]) + + const handleCreateNote = useCallback(() => { + onCreateNote(t('notes.untitled_note')) + }, [onCreateNote, t]) + + const handleStartEdit = useCallback((node: NotesTreeNode) => { + setEditingNodeId(node.id) + setEditingName(node.name) + }, []) + + const handleFinishEdit = useCallback(() => { + if (editingNodeId && editingName.trim()) { + onRenameNode(editingNodeId, editingName.trim()) + } + setEditingNodeId(null) + setEditingName('') + }, [editingNodeId, editingName, onRenameNode]) + + const handleCancelEdit = useCallback(() => { + setEditingNodeId(null) + setEditingName('') + }, []) + + const handleDeleteNode = useCallback( + (node: NotesTreeNode) => { + const confirmKey = node.type === 'folder' ? 'delete_folder_confirm' : 'delete_note_confirm' + + window.modal.confirm({ + title: t('notes.delete'), + content: t(`notes.${confirmKey}`, { name: node.name }), + centered: true, + okButtonProps: { danger: true }, + onOk: () => { + onDeleteNode(node.id) + } + }) + }, + [onDeleteNode, t] + ) + + const handleDragStart = useCallback((e: React.DragEvent, node: NotesTreeNode) => { + setDraggedNodeId(node.id) + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', node.id) + }, []) + + const handleDragOver = useCallback((e: React.DragEvent, node: NotesTreeNode) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + setDragOverNodeId(node.id) + }, []) + + const handleDragLeave = useCallback(() => { + setDragOverNodeId(null) + }, []) + + const handleDrop = useCallback( + (e: React.DragEvent, targetNode: NotesTreeNode) => { + e.preventDefault() + const draggedId = e.dataTransfer.getData('text/plain') + + if (draggedId && draggedId !== targetNode.id) { + const targetParentId = targetNode.type === 'folder' ? targetNode.id : undefined + onMoveNode(draggedId, targetParentId) + } + + setDraggedNodeId(null) + setDragOverNodeId(null) + }, + [onMoveNode] + ) + + const handleDragEnd = useCallback(() => { + setDraggedNodeId(null) + setDragOverNodeId(null) + }, []) + + const renderTreeNode = useCallback( + (node: NotesTreeNode, depth: number = 0) => { + const isActive = node.id === activeNodeId + const isEditing = editingNodeId === node.id + const hasChildren = node.children && node.children.length > 0 + const isDragging = draggedNodeId === node.id + const isDragOver = dragOverNodeId === node.id + + return ( +
+ handleDragStart(e, node)} + onDragOver={(e) => handleDragOver(e, node)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, node)} + onDragEnd={handleDragEnd}> + onSelectNode(node)}> + + + {node.type === 'folder' && ( + { + e.stopPropagation() + onToggleExpanded(node.id) + }} + title={node.expanded ? t('notes.collapse') : t('notes.expand')}> + {node.expanded ? : } + + )} + + + {node.type === 'folder' ? ( + node.expanded ? ( + + ) : ( + + ) + ) : ( + + )} + + + {isEditing ? ( + setEditingName(e.target.value)} + onPressEnter={handleFinishEdit} + onBlur={handleFinishEdit} + onKeyDown={(e) => { + if (e.key === 'Escape') { + handleCancelEdit() + } + }} + autoFocus + size="small" + /> + ) : ( + {node.name} + )} + + {node.is_starred && ( + + + + )} + + + + + { + e.stopPropagation() + onToggleStarred(node.id) + }}> + + + + + + { + e.stopPropagation() + handleStartEdit(node) + }}> + + + + + + { + e.stopPropagation() + handleDeleteNode(node) + }}> + + + + + + + {node.type === 'folder' && node.expanded && hasChildren && ( +
{node.children!.map((child) => renderTreeNode(child, depth + 1))}
+ )} +
+ ) + }, + [ + activeNodeId, + editingNodeId, + editingName, + draggedNodeId, + dragOverNodeId, + onSelectNode, + onToggleExpanded, + onToggleStarred, + handleStartEdit, + handleDeleteNode, + handleFinishEdit, + handleCancelEdit, + handleDragStart, + handleDragOver, + handleDragLeave, + handleDrop, + handleDragEnd, + t + ] + ) + + return ( + + + + + + + + + + + + + + + + + + + + {notesTree.map((node) => renderTreeNode(node))} + + + + ) +} + +const SidebarContainer = styled.div` + width: 280px; + height: 100vh; + background-color: var(--color-background); + border-right: 1px solid var(--color-border); + display: flex; + flex-direction: column; +` + +const SidebarHeader = styled.div` + padding: 8px 12px; + border-bottom: 1px solid var(--color-border); + display: flex; + justify-content: flex-end; +` + +const HeaderActions = styled.div` + display: flex; + align-items: center; + gap: 4px; +` + +const NotesTreeContainer = styled.div` + flex: 1; + overflow: hidden; +` + +const StyledScrollbar = styled(Scrollbar)` + height: 100%; +` + +const TreeContent = styled.div` + padding: 8px; +` + +const TreeNodeContainer = styled.div<{ active: boolean; depth: number; isDragging?: boolean; isDragOver?: boolean }>` + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 6px; + border-radius: 4px; + cursor: pointer; + margin-bottom: 2px; + background-color: ${(props) => { + if (props.isDragOver) return 'var(--color-primary-background)' + if (props.active) return 'var(--color-background-soft)' + return 'transparent' + }}; + border: 1px solid + ${(props) => { + if (props.isDragOver) return 'var(--color-primary)' + if (props.active) return 'var(--color-border)' + return 'transparent' + }}; + opacity: ${(props) => (props.isDragging ? 0.5 : 1)}; + transition: all 0.2s ease; + + &:hover { + background-color: var(--color-background-soft); + + .node-actions { + opacity: 1; + } + } +` + +const TreeNodeContent = styled.div` + display: flex; + align-items: center; + flex: 1; + min-width: 0; +` + +const NodeIndent = styled.div<{ depth: number }>` + width: ${(props) => props.depth * 16}px; + flex-shrink: 0; +` + +const ExpandIcon = styled.div` + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-2); + margin-right: 4px; + + &:hover { + color: var(--color-text); + } +` + +const NodeIcon = styled.div` + display: flex; + align-items: center; + justify-content: center; + margin-right: 8px; + color: var(--color-text-2); + flex-shrink: 0; +` + +const NodeName = styled.div` + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 13px; + color: var(--color-text); +` + +const StarIcon = styled.div` + display: flex; + align-items: center; + margin-left: 4px; + color: var(--color-primary); +` + +const EditInput = styled(Input)` + flex: 1; + font-size: 13px; + + .ant-input { + font-size: 13px; + padding: 2px 6px; + border: 1px solid var(--color-primary); + } +` + +const NodeActions = styled.div` + display: flex; + align-items: center; + gap: 2px; + opacity: 0; + transition: opacity 0.2s ease; +` + +const ActionButton = styled.div` + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 3px; + color: var(--color-text-2); + cursor: pointer; + + &:hover { + background-color: var(--color-background-soft); + color: var(--color-text); + } +` + +export default NotesSidebar diff --git a/src/renderer/src/pages/notes/utils/NotesService.ts b/src/renderer/src/pages/notes/utils/NotesService.ts new file mode 100644 index 0000000000..b7b53493a4 --- /dev/null +++ b/src/renderer/src/pages/notes/utils/NotesService.ts @@ -0,0 +1,321 @@ +import FileManager from '@renderer/services/FileManager' +import { FileTypes } from '@renderer/types/file' +import { NotesTreeNode } from '@renderer/types/note' +import { v4 as uuidv4 } from 'uuid' + +const NOTES_FOLDER_PREFIX = 'notes' + +export class NotesService { + private static readonly NOTES_STORAGE_KEY = 'notes-tree-structure' + + /** + * 获取笔记树结构 + */ + static async getNotesTree(): Promise { + try { + const storedTree = localStorage.getItem(this.NOTES_STORAGE_KEY) + if (storedTree) { + return JSON.parse(storedTree) + } + return [] + } catch (error) { + console.error('Failed to get notes tree:', error) + return [] + } + } + + /** + * 保存笔记树结构 + */ + static async saveNotesTree(tree: NotesTreeNode[]): Promise { + try { + localStorage.setItem(this.NOTES_STORAGE_KEY, JSON.stringify(tree)) + } catch (error) { + console.error('Failed to save notes tree:', error) + } + } + + /** + * 创建新文件夹 + */ + static async createFolder(name: string, parentId?: string): Promise { + const folderId = uuidv4() + const folderPath = this.buildPath(name, parentId) + + const folder: NotesTreeNode = { + id: folderId, + name, + type: 'folder', + path: folderPath, + children: [], + expanded: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + + const tree = await this.getNotesTree() + this.insertNodeIntoTree(tree, folder, parentId) + await this.saveNotesTree(tree) + + return folder + } + + /** + * 创建新笔记文件 + */ + static async createNote(name: string, content: string = '', parentId?: string): Promise { + const noteId = uuidv4() + const notePath = this.buildPath(name, parentId) + + try { + // 创建临时文件并写入内容 + const tempPath = await window.api.file.createTempFile(noteId) + await window.api.file.write(tempPath, content) + + // 通过FileManager上传文件 + const fileMetadata = await FileManager.uploadFile({ + id: noteId, + name, + origin_name: name, + path: tempPath, + size: content.length, + ext: '.md', + type: FileTypes.TEXT, + created_at: new Date().toISOString(), + count: 1 + }) + + const note: NotesTreeNode = { + id: noteId, + name, + type: 'file', + path: notePath, + fileId: fileMetadata.id, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + + const tree = await this.getNotesTree() + this.insertNodeIntoTree(tree, note, parentId) + await this.saveNotesTree(tree) + + return note + } catch (error) { + console.error('Failed to create note:', error) + throw error + } + } + + /** + * 读取笔记内容 + */ + static async readNote(node: NotesTreeNode): Promise { + if (node.type !== 'file' || !node.fileId) { + throw new Error('Invalid note node') + } + + try { + // 直接使用文件ID读取 + return await window.api.file.read(node.fileId) + } catch (error) { + console.error('Failed to read note:', error) + throw error + } + } + + /** + * 更新笔记内容 + */ + static async updateNote(node: NotesTreeNode, content: string): Promise { + if (node.type !== 'file' || !node.fileId) { + throw new Error('Invalid note node') + } + + try { + await window.api.file.writeWithId(node.fileId, content) + + // 更新树结构中的修改时间 + const tree = await this.getNotesTree() + const targetNode = this.findNodeInTree(tree, node.id) + if (targetNode) { + targetNode.updatedAt = new Date().toISOString() + await this.saveNotesTree(tree) + } + } catch (error) { + console.error('Failed to update note:', error) + throw error + } + } + + /** + * 删除笔记或文件夹 + */ + static async deleteNode(nodeId: string): Promise { + const tree = await this.getNotesTree() + const node = this.findNodeInTree(tree, nodeId) + + if (!node) { + throw new Error('Node not found') + } + + try { + // 递归删除所有子节点的文件 + await this.deleteNodeRecursively(node) + + // 从树结构中移除节点 + this.removeNodeFromTree(tree, nodeId) + await this.saveNotesTree(tree) + } catch (error) { + console.error('Failed to delete node:', error) + throw error + } + } + + /** + * 重命名节点 + */ + static async renameNode(nodeId: string, newName: string): Promise { + const tree = await this.getNotesTree() + const node = this.findNodeInTree(tree, nodeId) + + if (!node) { + throw new Error('Node not found') + } + + node.name = newName + node.updatedAt = new Date().toISOString() + + await this.saveNotesTree(tree) + } + + /** + * 切换节点展开状态 + */ + static async toggleNodeExpanded(nodeId: string): Promise { + const tree = await this.getNotesTree() + const node = this.findNodeInTree(tree, nodeId) + + if (node && node.type === 'folder') { + node.expanded = !node.expanded + await this.saveNotesTree(tree) + } + } + + /** + * 切换收藏状态 + */ + static async toggleStarred(nodeId: string): Promise { + const tree = await this.getNotesTree() + const node = this.findNodeInTree(tree, nodeId) + + if (node) { + node.is_starred = !node.is_starred + await this.saveNotesTree(tree) + } + } + + /** + * 移动节点到新的父节点 + */ + static async moveNode(nodeId: string, newParentId?: string): Promise { + const tree = await this.getNotesTree() + const node = this.findNodeInTree(tree, nodeId) + + if (!node) { + throw new Error('Node not found') + } + + // 从当前位置移除 + this.removeNodeFromTree(tree, nodeId) + + // 插入到新位置 + this.insertNodeIntoTree(tree, node, newParentId) + + await this.saveNotesTree(tree) + } + + /** + * 构建文件路径 + */ + private static buildPath(name: string, parentId?: string): string { + const segments = [NOTES_FOLDER_PREFIX] + + if (parentId) { + // 这里应该根据parentId构建完整路径,简化处理 + segments.push(parentId) + } + + segments.push(name) + return segments.join('/') + } + + /** + * 在树中插入节点 + */ + private static insertNodeIntoTree(tree: NotesTreeNode[], node: NotesTreeNode, parentId?: string): void { + if (!parentId) { + tree.push(node) + return + } + + const parent = this.findNodeInTree(tree, parentId) + if (parent && parent.type === 'folder') { + if (!parent.children) { + parent.children = [] + } + parent.children.push(node) + } + } + + /** + * 在树中查找节点 + */ + private static findNodeInTree(tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null { + for (const node of tree) { + if (node.id === nodeId) { + return node + } + if (node.children) { + const found = this.findNodeInTree(node.children, nodeId) + if (found) { + return found + } + } + } + return null + } + + /** + * 从树中移除节点 + */ + private static removeNodeFromTree(tree: NotesTreeNode[], nodeId: string): boolean { + for (let i = 0; i < tree.length; i++) { + if (tree[i].id === nodeId) { + tree.splice(i, 1) + return true + } + if (tree[i].children) { + const removed = this.removeNodeFromTree(tree[i].children!, nodeId) + if (removed) { + return true + } + } + } + return false + } + + /** + * 递归删除节点及其文件 + */ + private static async deleteNodeRecursively(node: NotesTreeNode): Promise { + if (node.type === 'file' && node.fileId) { + // 删除文件 + await FileManager.deleteFile(node.fileId) + } else if (node.type === 'folder' && node.children) { + // 递归删除子节点 + for (const child of node.children) { + await this.deleteNodeRecursively(child) + } + } + } +} diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 76668c547a..c580045cca 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1820,6 +1820,10 @@ const migrateConfig = { state.settings.sidebarIcons.visible = [...state.settings.sidebarIcons.visible, 'notes' as any] } } + + if (state.settings && state.settings.showWorkspace === undefined) { + state.settings.showWorkspace = true + } return state } } diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 607a83ca43..bce2750c0c 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -207,6 +207,8 @@ export interface SettingsState { localBackupSkipBackupFile: boolean defaultPaintingProvider: PaintingProvider s3: S3Config + // Notes Related + showWorkspace: boolean } export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid' @@ -373,7 +375,9 @@ export const initialState: SettingsState = { syncInterval: 0, maxBackups: 0, skipBackupFile: false - } + }, + // Notes Related + showWorkspace: true } const settingsSlice = createSlice({ @@ -765,6 +769,12 @@ const settingsSlice = createSlice({ }, setS3Partial: (state, action: PayloadAction>) => { state.s3 = { ...state.s3, ...action.payload } + }, + setShowWorkspace: (state, action: PayloadAction) => { + state.showWorkspace = action.payload + }, + toggleShowWorkspace: (state) => { + state.showWorkspace = !state.showWorkspace } } }) @@ -883,7 +893,9 @@ export const { setLocalBackupSkipBackupFile, setDefaultPaintingProvider, setS3, - setS3Partial + setS3Partial, + setShowWorkspace, + toggleShowWorkspace } = settingsSlice.actions export default settingsSlice.reducer diff --git a/src/renderer/src/types/note.ts b/src/renderer/src/types/note.ts index 54c75f1571..f0b3a4a7f4 100644 --- a/src/renderer/src/types/note.ts +++ b/src/renderer/src/types/note.ts @@ -1,93 +1,31 @@ -export type NoteType = 'note' | 'folder' +import { FileMetadata } from './file' -export interface Note { - id: string - title: string - content: string - type: NoteType - parentId?: string // For folder hierarchy - tags?: string[] - created_at: number - updated_at: number - isStarred?: boolean - isArchived?: boolean - wordCount?: number - readingTime?: number // in minutes - metadata?: { - lastCursorPosition?: number - isFullscreen?: boolean - fontSize?: number - theme?: string - } -} +export type SortType = 'name' | 'created' | 'modified' | 'none' +export type SortDirection = 'asc' | 'desc' -export interface NoteFolder { +/** + * @interface + * @description 笔记树节点接口 + */ +export interface NotesTreeNode { id: string name: string - type: 'folder' - parentId?: string - created_at: number - updated_at: number - isExpanded?: boolean - color?: string + type: 'folder' | 'file' + path: string + children?: NotesTreeNode[] + is_starred?: boolean + expanded?: boolean + fileId?: string // 文件类型节点对应的FileManager中的文件ID + createdAt: string + updatedAt: string } -export interface NoteFilter { - search?: string - tags?: string[] - isStarred?: boolean - isArchived?: boolean - parentId?: string - dateRange?: { - start: number - end: number - } +/** + * @interface + * @description 笔记文件接口,继承FileMetadata + */ +export interface NoteFile extends FileMetadata { + content?: string // 笔记内容 + parentId?: string // 父节点ID + isStarred?: boolean // 是否收藏 } - -export interface NoteSortOption { - field: 'title' | 'created_at' | 'updated_at' | 'wordCount' - order: 'asc' | 'desc' -} - -export interface NoteExportOptions { - format: 'markdown' | 'html' | 'pdf' | 'json' - includeMetadata?: boolean - includeTags?: boolean - includeArchived?: boolean -} - -export interface NoteImportOptions { - format: 'markdown' | 'html' | 'json' - targetFolderId?: string - preserveStructure?: boolean - mergeTags?: boolean -} - -export interface NoteStats { - totalNotes: number - totalFolders: number - totalWords: number - totalReadingTime: number - recentActivity: { - date: string - count: number - }[] -} - -export interface NoteEditorConfig { - theme: 'light' | 'dark' | 'auto' - fontSize: number - fontFamily: string - lineHeight: number - tabSize: number - wordWrap: boolean - showLineNumbers: boolean - enableVim: boolean - enableEmmet: boolean - autoSave: boolean - autoSaveInterval: number // in milliseconds - spellCheck: boolean - typewriterMode: boolean - focusMode: boolean - previewMode: 'split' | 'preview' | 'source' -} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 2e6903a99a..ae137cb179 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6989,6 +6989,7 @@ __metadata: undici: "npm:6.21.2" unified: "npm:^11.0.5" uuid: "npm:^10.0.0" + vditor: "npm:^3.11.1" vite: "npm:6.2.6" vitest: "npm:^3.1.4" webdav: "npm:^5.8.0" @@ -9558,6 +9559,13 @@ __metadata: languageName: node linkType: hard +"diff-match-patch@npm:^1.0.5": + version: 1.0.5 + resolution: "diff-match-patch@npm:1.0.5" + checksum: 10c0/142b6fad627b9ef309d11bd935e82b84c814165a02500f046e2773f4ea894d10ed3017ac20454900d79d4a0322079f5b713cf0986aaf15fce0ec4a2479980c86 + languageName: node + linkType: hard + "diff@npm:^7.0.0": version: 7.0.0 resolution: "diff@npm:7.0.0" @@ -19525,6 +19533,15 @@ __metadata: languageName: node linkType: hard +"vditor@npm:^3.11.1": + version: 3.11.1 + resolution: "vditor@npm:3.11.1" + dependencies: + diff-match-patch: "npm:^1.0.5" + checksum: 10c0/bd2386323690e66704ae91475f8e25a62c7b4c87cf814c25fce4bcc1f552e48883020087796c8c41c882a53238b8bf3b6b9ba9c16071969d4654cbd11eef2d9f + languageName: node + linkType: hard + "verror@npm:^1.10.0": version: 1.10.1 resolution: "verror@npm:1.10.1"