mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-29 14:31:35 +08:00
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:
parent
e2d3d7357d
commit
d4e2384f64
@ -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",
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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": "请输入笔记内容..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
99
src/renderer/src/pages/notes/NotesNavbar.tsx
Normal file
99
src/renderer/src/pages/notes/NotesNavbar.tsx
Normal 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
|
||||
@ -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
|
||||
|
||||
443
src/renderer/src/pages/notes/NotesSidebar.tsx
Normal file
443
src/renderer/src/pages/notes/NotesSidebar.tsx
Normal 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
|
||||
321
src/renderer/src/pages/notes/utils/NotesService.ts
Normal file
321
src/renderer/src/pages/notes/utils/NotesService.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
}
|
||||
17
yarn.lock
17
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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user