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.
This commit is contained in:
自由的世界人 2025-07-18 01:59:04 +08:00
parent e2d3d7357d
commit d4e2384f64
11 changed files with 1297 additions and 101 deletions

View File

@ -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",

View File

@ -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())
}
}

View File

@ -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": "请输入笔记内容..."
}
}
}

View File

@ -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 (
<Navbar className="notes-navbar">
{showWorkspace && (
<NavbarLeft style={{ justifyContent: 'space-between', borderRight: 'none', padding: 0 }}>
<Tooltip title={t('navbar.hide_sidebar')} mouseEnterDelay={0.8}>
<NavbarIcon onClick={handleToggleShowWorkspace} style={{ marginLeft: isMac && !isFullscreen ? 16 : 0 }}>
<PanelLeftClose size={18} />
</NavbarIcon>
</Tooltip>
</NavbarLeft>
)}
<NavbarRight style={{ justifyContent: 'space-between', flex: 1 }} className="notes-navbar-right">
<HStack alignItems="center">
{!showWorkspace && !sidebarHideCooldown && (
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={0.8}>
<NavbarIcon
onClick={handleToggleShowWorkspace}
style={{ marginRight: 8, marginLeft: isMac && !isFullscreen ? 4 : -12 }}>
<PanelRightClose size={18} />
</NavbarIcon>
</Tooltip>
)}
{!showWorkspace && sidebarHideCooldown && (
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={0.8}>
<NavbarIcon
onClick={handleToggleShowWorkspace}
style={{ marginRight: 8, marginLeft: isMac && !isFullscreen ? 4 : -12 }}
onMouseOut={() => setSidebarHideCooldown(false)}>
<PanelRightClose size={18} />
</NavbarIcon>
</Tooltip>
)}
</HStack>
</NavbarRight>
</Navbar>
)
}
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

View File

@ -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<HTMLDivElement>(null)
const [vditor, setVditor] = useState<Vditor | null>(null)
const { theme } = useTheme()
const { t } = useTranslation()
const { showWorkspace } = useSettings()
const [notesTree, setNotesTree] = useState<NotesTreeNode[]>([])
const [activeNodeId, setActiveNodeId] = useState<string | undefined>(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 (
<Container id="notes-page">
<Navbar>
<NavbarCenter style={{ borderRight: 'none', gap: 10 }}></NavbarCenter>
</Navbar>
<ContentContainer>
<h1></h1>
<p></p>
<NotesNavbar />
<ContentContainer id="content-container">
{showWorkspace && (
<NotesSidebar
notesTree={notesTree}
activeNodeId={activeNodeId}
onSelectNode={handleSelectNode}
onCreateFolder={handleCreateFolder}
onCreateNote={handleCreateNote}
onDeleteNode={handleDeleteNode}
onRenameNode={handleRenameNode}
onToggleStarred={handleToggleStarred}
onToggleExpanded={handleToggleExpanded}
onMoveNode={handleMoveNode}
/>
)}
{isLoading && (
<LoadingOverlay>
<LoadingText>{t('common.loading')}</LoadingText>
</LoadingOverlay>
)}
<EditorWrapper>
{activeNodeId ? (
<EditorContainer ref={editorRef} />
) : (
<MainContent>
<Empty description={t('notes.empty')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
</MainContent>
)}
</EditorWrapper>
</ContentContainer>
</Container>
)
@ -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

View File

@ -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<NotesSidebarProps> = ({
onCreateFolder,
onCreateNote,
onSelectNode,
onDeleteNode,
onRenameNode,
onToggleStarred,
onToggleExpanded,
onMoveNode,
activeNodeId,
notesTree
}) => {
const { t } = useTranslation()
const [editingNodeId, setEditingNodeId] = useState<string | null>(null)
const [editingName, setEditingName] = useState('')
const [draggedNodeId, setDraggedNodeId] = useState<string | null>(null)
const [dragOverNodeId, setDragOverNodeId] = useState<string | null>(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 (
<div key={node.id}>
<TreeNodeContainer
active={isActive}
depth={depth}
isDragging={isDragging}
isDragOver={isDragOver}
draggable={!isEditing}
onDragStart={(e) => handleDragStart(e, node)}
onDragOver={(e) => handleDragOver(e, node)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, node)}
onDragEnd={handleDragEnd}>
<TreeNodeContent onClick={() => onSelectNode(node)}>
<NodeIndent depth={depth} />
{node.type === 'folder' && (
<ExpandIcon
onClick={(e) => {
e.stopPropagation()
onToggleExpanded(node.id)
}}
title={node.expanded ? t('notes.collapse') : t('notes.expand')}>
{node.expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</ExpandIcon>
)}
<NodeIcon>
{node.type === 'folder' ? (
node.expanded ? (
<FolderOpen size={16} />
) : (
<Folder size={16} />
)
) : (
<File size={16} />
)}
</NodeIcon>
{isEditing ? (
<EditInput
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onPressEnter={handleFinishEdit}
onBlur={handleFinishEdit}
onKeyDown={(e) => {
if (e.key === 'Escape') {
handleCancelEdit()
}
}}
autoFocus
size="small"
/>
) : (
<NodeName>{node.name}</NodeName>
)}
{node.is_starred && (
<StarIcon>
<Star size={12} fill="var(--color-primary)" />
</StarIcon>
)}
</TreeNodeContent>
<NodeActions className="node-actions">
<Tooltip title={node.is_starred ? t('notes.unstar') : t('notes.star')}>
<ActionButton
onClick={(e) => {
e.stopPropagation()
onToggleStarred(node.id)
}}>
<Star size={14} fill={node.is_starred ? 'var(--color-primary)' : 'none'} />
</ActionButton>
</Tooltip>
<Tooltip title={t('notes.rename')}>
<ActionButton
onClick={(e) => {
e.stopPropagation()
handleStartEdit(node)
}}>
<Edit3 size={14} />
</ActionButton>
</Tooltip>
<Tooltip title={t('notes.delete')}>
<ActionButton
onClick={(e) => {
e.stopPropagation()
handleDeleteNode(node)
}}>
<Trash2 size={14} />
</ActionButton>
</Tooltip>
</NodeActions>
</TreeNodeContainer>
{node.type === 'folder' && node.expanded && hasChildren && (
<div>{node.children!.map((child) => renderTreeNode(child, depth + 1))}</div>
)}
</div>
)
},
[
activeNodeId,
editingNodeId,
editingName,
draggedNodeId,
dragOverNodeId,
onSelectNode,
onToggleExpanded,
onToggleStarred,
handleStartEdit,
handleDeleteNode,
handleFinishEdit,
handleCancelEdit,
handleDragStart,
handleDragOver,
handleDragLeave,
handleDrop,
handleDragEnd,
t
]
)
return (
<SidebarContainer>
<SidebarHeader>
<HeaderActions>
<Tooltip title={t('notes.new_folder')} mouseEnterDelay={0.8}>
<ActionButton onClick={handleCreateFolder}>
<FolderPlus size={18} />
</ActionButton>
</Tooltip>
<Tooltip title={t('notes.new_note')} mouseEnterDelay={0.8}>
<ActionButton onClick={handleCreateNote}>
<FilePlus size={18} />
</ActionButton>
</Tooltip>
</HeaderActions>
</SidebarHeader>
<NotesTreeContainer>
<StyledScrollbar>
<TreeContent>{notesTree.map((node) => renderTreeNode(node))}</TreeContent>
</StyledScrollbar>
</NotesTreeContainer>
</SidebarContainer>
)
}
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

View File

@ -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<NotesTreeNode[]> {
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<void> {
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<NotesTreeNode> {
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<NotesTreeNode> {
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<string> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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)
}
}
}
}

View File

@ -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
}
}

View File

@ -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<Partial<S3Config>>) => {
state.s3 = { ...state.s3, ...action.payload }
},
setShowWorkspace: (state, action: PayloadAction<boolean>) => {
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

View File

@ -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'
}

View File

@ -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"