- {virtualizer.getVirtualItems().map((virtualItem) => {
- const { node, depth } = flattenedNodes[virtualItem.index]
- return (
-
-
+
+ {isShowSearch && isSearching && (
+
+
+ {t('notes.search.searching')}
+
+
+
+
+ )}
+ {isShowSearch && !isSearching && hasSearchKeyword && searchStats.total > 0 && (
+
+
+ {t('notes.search.found_results', {
+ count: searchStats.total,
+ nameCount: searchStats.fileNameMatches,
+ contentCount: searchStats.contentMatches + searchStats.bothMatches
+ })}
+
+
+ )}
+ setOpenDropdownKey(open ? 'empty-area' : null)}>
+ 28}
+ itemContainerStyle={{ padding: '0 8px' }}
+ overscan={10}
+ isSticky={isSticky}
+ getItemDepth={getItemDepth}>
+ {({ node, depth }) => }
+
+
+ {!isShowStarred && !isShowSearch && (
+
-
- )
- })}
-
- {!isShowStarred && !isShowSearch && (
-
-
-
-
-
-
- {t('notes.drop_markdown_hint')}
-
-
-
- )}
-
-
- ) : (
-
setOpenDropdownKey(open ? 'empty-area' : null)}>
-
-
- {isShowStarred || isShowSearch
- ? filteredTree.map((node) => (
-
- ))
- : notesTree.map((node) => (
-
- ))}
- {!isShowStarred && !isShowSearch && (
-
-
-
-
-
-
- {t('notes.drop_markdown_hint')}
-
-
-
- )}
-
-
-
- )}
-
+ )}
+
- {isDragOverSidebar &&
}
-
+ {isDragOverSidebar &&
}
+
+
+
+
+
+
+
)
}
-const SidebarContainer = styled.div`
+export const SidebarContainer = styled.div`
width: 250px;
min-width: 250px;
height: calc(100vh - var(--navbar-height));
@@ -1204,7 +466,7 @@ const SidebarContainer = styled.div`
position: relative;
`
-const NotesTreeContainer = styled.div`
+export const NotesTreeContainer = styled.div`
flex: 1;
overflow: hidden;
display: flex;
@@ -1212,183 +474,7 @@ const NotesTreeContainer = styled.div`
height: calc(100vh - var(--navbar-height) - 45px);
`
-const VirtualizedTreeContainer = styled.div`
- flex: 1;
- height: 100%;
- overflow: auto;
- position: relative;
- padding-top: 10px;
-`
-
-const StyledScrollbar = styled(Scrollbar)`
- flex: 1;
- height: 100%;
- min-height: 0;
-`
-
-const TreeContent = styled.div`
- padding: 8px;
-`
-
-const TreeNodeContainer = styled.div<{
- active: boolean
- depth: number
- isDragging?: boolean
- isDragOver?: boolean
- isDragBefore?: boolean
- isDragInside?: boolean
- isDragAfter?: 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.isDragInside) return 'var(--color-primary-background)'
- if (props.active) return 'var(--color-background-soft)'
- return 'transparent'
- }};
- border: 0.5px solid
- ${(props) => {
- if (props.isDragInside) 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;
- position: relative;
-
- &:hover {
- background-color: var(--color-background-soft);
-
- .node-actions {
- opacity: 1;
- }
- }
-
- /* 添加拖拽指示线 */
- ${(props) =>
- props.isDragBefore &&
- `
- &::before {
- content: '';
- position: absolute;
- top: -2px;
- left: 0;
- right: 0;
- height: 2px;
- background-color: var(--color-primary);
- border-radius: 1px;
- }
- `}
-
- ${(props) =>
- props.isDragAfter &&
- `
- &::after {
- content: '';
- position: absolute;
- bottom: -2px;
- left: 0;
- right: 0;
- height: 2px;
- background-color: var(--color-primary);
- border-radius: 1px;
- }
- `}
-`
-
-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);
- position: relative;
- will-change: background-position, width;
-
- --color-shimmer-mid: var(--color-text-1);
- --color-shimmer-end: color-mix(in srgb, var(--color-text-1) 25%, transparent);
-
- &.shimmer {
- background: linear-gradient(to left, var(--color-shimmer-end), var(--color-shimmer-mid), var(--color-shimmer-end));
- background-size: 200% 100%;
- background-clip: text;
- color: transparent;
- animation: shimmer 3s linear infinite;
- }
-
- &.typing {
- display: block;
- white-space: nowrap;
- overflow: hidden;
- animation: typewriter 0.5s steps(40, end);
- }
-
- @keyframes shimmer {
- 0% {
- background-position: 200% 0;
- }
- 100% {
- background-position: -200% 0;
- }
- }
-
- @keyframes typewriter {
- from {
- width: 0;
- }
- to {
- width: 100%;
- }
- }
-`
-
-const EditInput = styled(Input)`
- flex: 1;
- font-size: 13px;
-`
-
-const DragOverIndicator = styled.div`
+export const DragOverIndicator = styled.div`
position: absolute;
top: 0;
right: 0;
@@ -1400,31 +486,14 @@ const DragOverIndicator = styled.div`
pointer-events: none;
`
-const DropHintNode = styled.div`
- margin: 6px 0;
- margin-bottom: 20px;
-
- ${TreeNodeContainer} {
- background-color: transparent;
- border: 1px dashed var(--color-border);
- cursor: default;
- opacity: 0.6;
-
- &:hover {
- background-color: var(--color-background-soft);
- opacity: 0.8;
- }
- }
-`
-
-const DropHintText = styled.div`
+export const DropHintText = styled.div`
color: var(--color-text-3);
font-size: 12px;
font-style: italic;
`
// 搜索相关样式
-const SearchStatusBar = styled.div`
+export const SearchStatusBar = styled.div`
display: flex;
align-items: center;
gap: 8px;
@@ -1448,7 +517,7 @@ const SearchStatusBar = styled.div`
}
`
-const CancelButton = styled.button`
+export const CancelButton = styled.button`
margin-left: auto;
display: flex;
align-items: center;
@@ -1473,98 +542,4 @@ const CancelButton = styled.button`
}
`
-const NodeNameContainer = styled.div`
- display: flex;
- align-items: center;
- gap: 6px;
- flex: 1;
- min-width: 0;
-`
-
-const MatchBadge = styled.span<{ matchType: string }>`
- display: inline-flex;
- align-items: center;
- padding: 0 4px;
- height: 16px;
- font-size: 10px;
- line-height: 1;
- border-radius: 2px;
- background-color: ${(props) =>
- props.matchType === 'both' ? 'var(--color-primary-soft)' : 'var(--color-background-mute)'};
- color: ${(props) => (props.matchType === 'both' ? 'var(--color-primary)' : 'var(--color-text-3)')};
- font-weight: 500;
- flex-shrink: 0;
-`
-
-const SearchMatchesContainer = styled.div<{ depth: number }>`
- margin-left: ${(props) => props.depth * 16 + 40}px;
- margin-top: 4px;
- margin-bottom: 8px;
- padding: 6px 8px;
- background-color: var(--color-background-mute);
- border-radius: 4px;
- border-left: 2px solid var(--color-primary-soft);
-`
-
-const MatchItem = styled.div`
- display: flex;
- gap: 8px;
- margin-bottom: 4px;
- font-size: 12px;
- padding: 4px 6px;
- margin-left: -6px;
- margin-right: -6px;
- border-radius: 3px;
- cursor: pointer;
- transition: all 0.15s ease;
-
- &:hover {
- background-color: var(--color-background-soft);
- transform: translateX(2px);
- }
-
- &:active {
- background-color: var(--color-active);
- }
-
- &:last-child {
- margin-bottom: 0;
- }
-`
-
-const MatchLineNumber = styled.span`
- color: var(--color-text-3);
- font-family: monospace;
- flex-shrink: 0;
- width: 30px;
-`
-
-const MatchContext = styled.div`
- color: var(--color-text-2);
- flex: 1;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- font-family: monospace;
-`
-
-const MoreMatches = styled.div<{ depth: number }>`
- margin-top: 4px;
- padding: 4px 6px;
- margin-left: -6px;
- margin-right: -6px;
- font-size: 11px;
- color: var(--color-text-3);
- border-radius: 3px;
- cursor: pointer;
- display: flex;
- align-items: center;
- transition: all 0.15s ease;
-
- &:hover {
- color: var(--color-text-2);
- background-color: var(--color-background-soft);
- }
-`
-
export default memo(NotesSidebar)
diff --git a/src/renderer/src/pages/notes/components/TreeNode.tsx b/src/renderer/src/pages/notes/components/TreeNode.tsx
new file mode 100644
index 0000000000..0801d31050
--- /dev/null
+++ b/src/renderer/src/pages/notes/components/TreeNode.tsx
@@ -0,0 +1,498 @@
+import HighlightText from '@renderer/components/HighlightText'
+import {
+ useNotesActions,
+ useNotesDrag,
+ useNotesEditing,
+ useNotesSearch,
+ useNotesSelection,
+ useNotesUI
+} from '@renderer/pages/notes/context/NotesContexts'
+import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
+import type { SearchMatch, SearchResult } from '@renderer/services/NotesSearchService'
+import type { NotesTreeNode } from '@renderer/types/note'
+import { Dropdown } from 'antd'
+import { ChevronDown, ChevronRight, File, FilePlus, Folder, FolderOpen } from 'lucide-react'
+import { memo, useCallback, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import styled from 'styled-components'
+
+interface TreeNodeProps {
+ node: NotesTreeNode | SearchResult
+ depth: number
+ renderChildren?: boolean
+ onHintClick?: () => void
+}
+
+const TreeNode = memo
(({ node, depth, renderChildren = true, onHintClick }) => {
+ const { t } = useTranslation()
+
+ // Use split contexts - only subscribe to what this node needs
+ const { selectedFolderId, activeNodeId } = useNotesSelection()
+ const { editingNodeId, renamingNodeIds, newlyRenamedNodeIds, inPlaceEdit } = useNotesEditing()
+ const { draggedNodeId, dragOverNodeId, dragPosition, onDragStart, onDragOver, onDragLeave, onDrop, onDragEnd } =
+ useNotesDrag()
+ const { searchKeyword, showMatches } = useNotesSearch()
+ const { openDropdownKey } = useNotesUI()
+ const { getMenuItems, onSelectNode, onToggleExpanded, onDropdownOpenChange } = useNotesActions()
+
+ const [showAllMatches, setShowAllMatches] = useState(false)
+ const { isEditing: isInputEditing, inputProps } = inPlaceEdit
+
+ // 检查是否是 hint 节点
+ const isHintNode = node.type === 'hint'
+
+ // 检查是否是搜索结果
+ const searchResult = 'matchType' in node ? (node as SearchResult) : null
+ const hasMatches = searchResult && searchResult.matches && searchResult.matches.length > 0
+
+ // 处理匹配项点击
+ const handleMatchClick = useCallback(
+ (match: SearchMatch) => {
+ // 发送定位事件
+ EventEmitter.emit(EVENT_NAMES.LOCATE_NOTE_LINE, {
+ noteId: node.id,
+ lineNumber: match.lineNumber,
+ lineContent: match.lineContent
+ })
+ },
+ [node]
+ )
+
+ const isActive = selectedFolderId ? node.type === 'folder' && node.id === selectedFolderId : node.id === activeNodeId
+ const isEditing = editingNodeId === node.id && isInputEditing
+ const isRenaming = renamingNodeIds.has(node.id)
+ const isNewlyRenamed = newlyRenamedNodeIds.has(node.id)
+ const hasChildren = node.children && node.children.length > 0
+ const isDragging = draggedNodeId === node.id
+ const isDragOver = dragOverNodeId === node.id
+ const isDragBefore = isDragOver && dragPosition === 'before'
+ const isDragInside = isDragOver && dragPosition === 'inside'
+ const isDragAfter = isDragOver && dragPosition === 'after'
+
+ const getNodeNameClassName = () => {
+ if (isRenaming) return 'shimmer'
+ if (isNewlyRenamed) return 'typing'
+ return ''
+ }
+
+ const displayName = useMemo(() => {
+ if (!searchKeyword) {
+ return node.name
+ }
+
+ const name = node.name ?? ''
+ if (!name) {
+ return name
+ }
+
+ const keyword = searchKeyword
+ const nameLower = name.toLowerCase()
+ const keywordLower = keyword.toLowerCase()
+ const matchStart = nameLower.indexOf(keywordLower)
+
+ if (matchStart === -1) {
+ return name
+ }
+
+ const matchEnd = matchStart + keyword.length
+ const beforeMatch = Math.min(2, matchStart)
+ const contextStart = matchStart - beforeMatch
+ const contextLength = 50
+ const contextEnd = Math.min(name.length, matchEnd + contextLength)
+
+ const prefix = contextStart > 0 ? '...' : ''
+ const suffix = contextEnd < name.length ? '...' : ''
+
+ return prefix + name.substring(contextStart, contextEnd) + suffix
+ }, [node.name, searchKeyword])
+
+ // Special render for hint nodes
+ if (isHintNode) {
+ return (
+
+
+
+
+
+
+ {t('notes.drop_markdown_hint')}
+
+
+
+ )
+ }
+
+ return (
+
+
onDropdownOpenChange(open ? node.id : null)}>
+ e.stopPropagation()}>
+ onDragStart(e, node as NotesTreeNode)}
+ onDragOver={(e) => onDragOver(e, node as NotesTreeNode)}
+ onDragLeave={onDragLeave}
+ onDrop={(e) => onDrop(e, node as NotesTreeNode)}
+ onDragEnd={onDragEnd}>
+ onSelectNode(node as NotesTreeNode)}>
+
+
+ {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 ? (
+ e.stopPropagation()} autoFocus />
+ ) : (
+
+
+ {searchKeyword ? : node.name}
+
+ {searchResult && searchResult.matchType && searchResult.matchType !== 'filename' && (
+
+ {searchResult.matchType === 'both' ? t('notes.search.both') : t('notes.search.content')}
+
+ )}
+
+ )}
+
+
+
+
+
+ {showMatches && hasMatches && (
+
+ {(showAllMatches ? searchResult!.matches! : searchResult!.matches!.slice(0, 3)).map((match, idx) => (
+ handleMatchClick(match)}>
+ {match.lineNumber}
+
+
+
+
+ ))}
+ {searchResult!.matches!.length > 3 && (
+ {
+ e.stopPropagation()
+ setShowAllMatches(!showAllMatches)
+ }}>
+ {showAllMatches ? (
+ <>
+
+ {t('notes.search.show_less')}
+ >
+ ) : (
+ <>
+ +{searchResult!.matches!.length - 3}{' '}
+ {t('notes.search.more_matches')}
+ >
+ )}
+
+ )}
+
+ )}
+
+ {renderChildren && node.type === 'folder' && node.expanded && hasChildren && (
+
+ {node.children!.map((child) => (
+
+ ))}
+
+ )}
+
+ )
+})
+
+export const TreeNodeContainer = styled.div<{
+ active: boolean
+ depth: number
+ isDragging?: boolean
+ isDragOver?: boolean
+ isDragBefore?: boolean
+ isDragInside?: boolean
+ isDragAfter?: boolean
+}>`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 4px 6px;
+ border-radius: 4px;
+ cursor: pointer;
+ margin-bottom: 2px;
+ /* CRITICAL: Must have fully opaque background for sticky to work properly */
+ /* Transparent/semi-transparent backgrounds will show content bleeding through when sticky */
+ background-color: ${(props) => {
+ if (props.isDragInside) return 'var(--color-primary-background)'
+ // Use hover color for active state - it's guaranteed to be opaque
+ if (props.active) return 'var(--color-hover, var(--color-background-mute))'
+ return 'var(--color-background)'
+ }};
+ border: 0.5px solid
+ ${(props) => {
+ if (props.isDragInside) 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;
+ position: relative;
+
+ &:hover {
+ background-color: var(--color-background-soft);
+
+ .node-actions {
+ opacity: 1;
+ }
+ }
+
+ /* 添加拖拽指示线 */
+ ${(props) =>
+ props.isDragBefore &&
+ `
+ &::before {
+ content: '';
+ position: absolute;
+ top: -2px;
+ left: 0;
+ right: 0;
+ height: 2px;
+ background-color: var(--color-primary);
+ border-radius: 1px;
+ }
+ `}
+
+ ${(props) =>
+ props.isDragAfter &&
+ `
+ &::after {
+ content: '';
+ position: absolute;
+ bottom: -2px;
+ left: 0;
+ right: 0;
+ height: 2px;
+ background-color: var(--color-primary);
+ border-radius: 1px;
+ }
+ `}
+`
+
+export const TreeNodeContent = styled.div`
+ display: flex;
+ align-items: center;
+ flex: 1;
+ min-width: 0;
+`
+
+export const NodeIndent = styled.div<{ depth: number }>`
+ width: ${(props) => props.depth * 16}px;
+ flex-shrink: 0;
+`
+
+export 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);
+ }
+`
+
+export const NodeIcon = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-right: 8px;
+ color: var(--color-text-2);
+ flex-shrink: 0;
+`
+
+export const NodeName = styled.div`
+ flex: 1;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-size: 13px;
+ color: var(--color-text);
+ position: relative;
+ will-change: background-position, width;
+
+ --color-shimmer-mid: var(--color-text-1);
+ --color-shimmer-end: color-mix(in srgb, var(--color-text-1) 25%, transparent);
+
+ &.shimmer {
+ background: linear-gradient(to left, var(--color-shimmer-end), var(--color-shimmer-mid), var(--color-shimmer-end));
+ background-size: 200% 100%;
+ background-clip: text;
+ color: transparent;
+ animation: shimmer 3s linear infinite;
+ }
+
+ &.typing {
+ display: block;
+ white-space: nowrap;
+ overflow: hidden;
+ animation: typewriter 0.5s steps(40, end);
+ }
+
+ @keyframes shimmer {
+ 0% {
+ background-position: 200% 0;
+ }
+ 100% {
+ background-position: -200% 0;
+ }
+ }
+
+ @keyframes typewriter {
+ from {
+ width: 0;
+ }
+ to {
+ width: 100%;
+ }
+ }
+`
+
+export const SearchMatchesContainer = styled.div<{ depth: number }>`
+ margin-left: ${(props) => props.depth * 16 + 40}px;
+ margin-top: 4px;
+ margin-bottom: 8px;
+ padding: 6px 8px;
+ background-color: var(--color-background-mute);
+ border-radius: 4px;
+ border-left: 2px solid var(--color-primary-soft);
+`
+
+export const NodeNameContainer = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex: 1;
+ min-width: 0;
+`
+
+export const MatchBadge = styled.span<{ matchType: string }>`
+ display: inline-flex;
+ align-items: center;
+ padding: 0 4px;
+ height: 16px;
+ font-size: 10px;
+ line-height: 1;
+ border-radius: 2px;
+ background-color: ${(props) =>
+ props.matchType === 'both' ? 'var(--color-primary-soft)' : 'var(--color-background-mute)'};
+ color: ${(props) => (props.matchType === 'both' ? 'var(--color-primary)' : 'var(--color-text-3)')};
+ font-weight: 500;
+ flex-shrink: 0;
+`
+
+export const MatchItem = styled.div`
+ display: flex;
+ gap: 8px;
+ margin-bottom: 4px;
+ font-size: 12px;
+ padding: 4px 6px;
+ margin-left: -6px;
+ margin-right: -6px;
+ border-radius: 3px;
+ cursor: pointer;
+ transition: all 0.15s ease;
+
+ &:hover {
+ background-color: var(--color-background-soft);
+ transform: translateX(2px);
+ }
+
+ &:active {
+ background-color: var(--color-active);
+ }
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+`
+
+export const MatchLineNumber = styled.span`
+ color: var(--color-text-3);
+ font-family: monospace;
+ flex-shrink: 0;
+ width: 30px;
+`
+
+export const MatchContext = styled.div`
+ color: var(--color-text-2);
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-family: monospace;
+`
+
+export const MoreMatches = styled.div<{ depth: number }>`
+ margin-top: 4px;
+ padding: 4px 6px;
+ margin-left: -6px;
+ margin-right: -6px;
+ font-size: 11px;
+ color: var(--color-text-3);
+ border-radius: 3px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ transition: all 0.15s ease;
+
+ &:hover {
+ color: var(--color-text-2);
+ background-color: var(--color-background-soft);
+ }
+`
+
+const EditInput = styled.input`
+ flex: 1;
+ font-size: 13px;
+`
+
+const DropHintText = styled.div`
+ color: var(--color-text-3);
+ font-size: 12px;
+ font-style: italic;
+`
+
+export default TreeNode
diff --git a/src/renderer/src/pages/notes/context/NotesContexts.tsx b/src/renderer/src/pages/notes/context/NotesContexts.tsx
new file mode 100644
index 0000000000..6bbb86c8d1
--- /dev/null
+++ b/src/renderer/src/pages/notes/context/NotesContexts.tsx
@@ -0,0 +1,109 @@
+import type { UseInPlaceEditReturn } from '@renderer/hooks/useInPlaceEdit'
+import type { NotesTreeNode } from '@renderer/types/note'
+import type { MenuProps } from 'antd'
+import { createContext, use } from 'react'
+
+// ==================== 1. Actions Context (Static, rarely changes) ====================
+export interface NotesActionsContextType {
+ getMenuItems: (node: NotesTreeNode) => MenuProps['items']
+ onSelectNode: (node: NotesTreeNode) => void
+ onToggleExpanded: (nodeId: string) => void
+ onDropdownOpenChange: (key: string | null) => void
+}
+
+export const NotesActionsContext = createContext(null)
+
+export const useNotesActions = () => {
+ const context = use(NotesActionsContext)
+ if (!context) {
+ throw new Error('useNotesActions must be used within NotesActionsContext.Provider')
+ }
+ return context
+}
+
+// ==================== 2. Selection Context (Low frequency updates) ====================
+export interface NotesSelectionContextType {
+ selectedFolderId?: string | null
+ activeNodeId?: string
+}
+
+export const NotesSelectionContext = createContext(null)
+
+export const useNotesSelection = () => {
+ const context = use(NotesSelectionContext)
+ if (!context) {
+ throw new Error('useNotesSelection must be used within NotesSelectionContext.Provider')
+ }
+ return context
+}
+
+// ==================== 3. Editing Context (Medium frequency updates) ====================
+export interface NotesEditingContextType {
+ editingNodeId: string | null
+ renamingNodeIds: Set
+ newlyRenamedNodeIds: Set
+ inPlaceEdit: UseInPlaceEditReturn
+}
+
+export const NotesEditingContext = createContext(null)
+
+export const useNotesEditing = () => {
+ const context = use(NotesEditingContext)
+ if (!context) {
+ throw new Error('useNotesEditing must be used within NotesEditingContext.Provider')
+ }
+ return context
+}
+
+// ==================== 4. Drag Context (High frequency updates) ====================
+export interface NotesDragContextType {
+ draggedNodeId: string | null
+ dragOverNodeId: string | null
+ dragPosition: 'before' | 'inside' | 'after'
+ onDragStart: (e: React.DragEvent, node: NotesTreeNode) => void
+ onDragOver: (e: React.DragEvent, node: NotesTreeNode) => void
+ onDragLeave: () => void
+ onDrop: (e: React.DragEvent, node: NotesTreeNode) => void
+ onDragEnd: () => void
+}
+
+export const NotesDragContext = createContext(null)
+
+export const useNotesDrag = () => {
+ const context = use(NotesDragContext)
+ if (!context) {
+ throw new Error('useNotesDrag must be used within NotesDragContext.Provider')
+ }
+ return context
+}
+
+// ==================== 5. Search Context (Medium frequency updates) ====================
+export interface NotesSearchContextType {
+ searchKeyword: string
+ showMatches: boolean
+}
+
+export const NotesSearchContext = createContext(null)
+
+export const useNotesSearch = () => {
+ const context = use(NotesSearchContext)
+ if (!context) {
+ throw new Error('useNotesSearch must be used within NotesSearchContext.Provider')
+ }
+ return context
+}
+
+// ==================== 6. UI Context (Medium frequency updates) ====================
+export interface NotesUIContextType {
+ openDropdownKey: string | null
+}
+
+export const NotesUIContext = createContext(null)
+
+export const useNotesUI = () => {
+ const context = use(NotesUIContext)
+ if (!context) {
+ throw new Error('useNotesUI must be used within NotesUIContext.Provider')
+ }
+ return context
+}
diff --git a/src/renderer/src/pages/notes/hooks/useNotesDragAndDrop.ts b/src/renderer/src/pages/notes/hooks/useNotesDragAndDrop.ts
new file mode 100644
index 0000000000..1822c00e9d
--- /dev/null
+++ b/src/renderer/src/pages/notes/hooks/useNotesDragAndDrop.ts
@@ -0,0 +1,101 @@
+import type { NotesTreeNode } from '@renderer/types/note'
+import { useCallback, useRef, useState } from 'react'
+
+interface UseNotesDragAndDropProps {
+ onMoveNode: (sourceNodeId: string, targetNodeId: string, position: 'before' | 'after' | 'inside') => void
+}
+
+export const useNotesDragAndDrop = ({ onMoveNode }: UseNotesDragAndDropProps) => {
+ const [draggedNodeId, setDraggedNodeId] = useState(null)
+ const [dragOverNodeId, setDragOverNodeId] = useState(null)
+ const [dragPosition, setDragPosition] = useState<'before' | 'inside' | 'after'>('inside')
+ const dragNodeRef = useRef(null)
+
+ const handleDragStart = useCallback((e: React.DragEvent, node: NotesTreeNode) => {
+ setDraggedNodeId(node.id)
+ e.dataTransfer.effectAllowed = 'move'
+ e.dataTransfer.setData('text/plain', node.id)
+
+ dragNodeRef.current = e.currentTarget as HTMLDivElement
+
+ // Create ghost element
+ if (e.currentTarget.parentElement) {
+ const rect = e.currentTarget.getBoundingClientRect()
+ const ghostElement = e.currentTarget.cloneNode(true) as HTMLElement
+ ghostElement.style.width = `${rect.width}px`
+ ghostElement.style.opacity = '0.7'
+ ghostElement.style.position = 'absolute'
+ ghostElement.style.top = '-1000px'
+ document.body.appendChild(ghostElement)
+ e.dataTransfer.setDragImage(ghostElement, 10, 10)
+ setTimeout(() => {
+ document.body.removeChild(ghostElement)
+ }, 0)
+ }
+ }, [])
+
+ const handleDragOver = useCallback(
+ (e: React.DragEvent, node: NotesTreeNode) => {
+ e.preventDefault()
+ e.dataTransfer.dropEffect = 'move'
+
+ if (draggedNodeId === node.id) {
+ return
+ }
+
+ setDragOverNodeId(node.id)
+
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
+ const mouseY = e.clientY
+ const thresholdTop = rect.top + rect.height * 0.3
+ const thresholdBottom = rect.bottom - rect.height * 0.3
+
+ if (mouseY < thresholdTop) {
+ setDragPosition('before')
+ } else if (mouseY > thresholdBottom) {
+ setDragPosition('after')
+ } else {
+ setDragPosition(node.type === 'folder' ? 'inside' : 'after')
+ }
+ },
+ [draggedNodeId]
+ )
+
+ const handleDragLeave = useCallback(() => {
+ setDragOverNodeId(null)
+ setDragPosition('inside')
+ }, [])
+
+ const handleDrop = useCallback(
+ (e: React.DragEvent, targetNode: NotesTreeNode) => {
+ e.preventDefault()
+ const draggedId = e.dataTransfer.getData('text/plain')
+
+ if (draggedId && draggedId !== targetNode.id) {
+ onMoveNode(draggedId, targetNode.id, dragPosition)
+ }
+
+ setDraggedNodeId(null)
+ setDragOverNodeId(null)
+ setDragPosition('inside')
+ },
+ [onMoveNode, dragPosition]
+ )
+
+ const handleDragEnd = useCallback(() => {
+ setDraggedNodeId(null)
+ setDragOverNodeId(null)
+ setDragPosition('inside')
+ }, [])
+
+ return {
+ draggedNodeId,
+ dragOverNodeId,
+ dragPosition,
+ handleDragStart,
+ handleDragOver,
+ handleDragLeave,
+ handleDrop,
+ handleDragEnd
+ }
+}
diff --git a/src/renderer/src/pages/notes/hooks/useNotesEditing.ts b/src/renderer/src/pages/notes/hooks/useNotesEditing.ts
new file mode 100644
index 0000000000..58cbdee9e3
--- /dev/null
+++ b/src/renderer/src/pages/notes/hooks/useNotesEditing.ts
@@ -0,0 +1,94 @@
+import { loggerService } from '@logger'
+import { useInPlaceEdit } from '@renderer/hooks/useInPlaceEdit'
+import { fetchNoteSummary } from '@renderer/services/ApiService'
+import type { NotesTreeNode } from '@renderer/types/note'
+import { useCallback, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+
+const logger = loggerService.withContext('UseNotesEditing')
+
+interface UseNotesEditingProps {
+ onRenameNode: (nodeId: string, newName: string) => void
+}
+
+export const useNotesEditing = ({ onRenameNode }: UseNotesEditingProps) => {
+ const { t } = useTranslation()
+ const [editingNodeId, setEditingNodeId] = useState(null)
+ const [renamingNodeIds, setRenamingNodeIds] = useState>(new Set())
+ const [newlyRenamedNodeIds, setNewlyRenamedNodeIds] = useState>(new Set())
+
+ const inPlaceEdit = useInPlaceEdit({
+ onSave: (newName: string) => {
+ if (editingNodeId && newName) {
+ onRenameNode(editingNodeId, newName)
+ window.toast.success(t('common.saved'))
+ logger.debug(`Renamed node ${editingNodeId} to "${newName}"`)
+ }
+ setEditingNodeId(null)
+ },
+ onCancel: () => {
+ setEditingNodeId(null)
+ }
+ })
+
+ const handleStartEdit = useCallback(
+ (node: NotesTreeNode) => {
+ setEditingNodeId(node.id)
+ inPlaceEdit.startEdit(node.name)
+ },
+ [inPlaceEdit]
+ )
+
+ const handleAutoRename = useCallback(
+ async (note: NotesTreeNode) => {
+ if (note.type !== 'file') return
+
+ setRenamingNodeIds((prev) => new Set(prev).add(note.id))
+ try {
+ const content = await window.api.file.readExternal(note.externalPath)
+ if (!content || content.trim().length === 0) {
+ window.toast.warning(t('notes.auto_rename.empty_note'))
+ return
+ }
+
+ const summaryText = await fetchNoteSummary({ content })
+ if (summaryText) {
+ onRenameNode(note.id, summaryText)
+ window.toast.success(t('notes.auto_rename.success'))
+ } else {
+ window.toast.error(t('notes.auto_rename.failed'))
+ }
+ } catch (error) {
+ window.toast.error(t('notes.auto_rename.failed'))
+ logger.error(`Failed to auto-rename note: ${error}`)
+ } finally {
+ setRenamingNodeIds((prev) => {
+ const next = new Set(prev)
+ next.delete(note.id)
+ return next
+ })
+
+ setNewlyRenamedNodeIds((prev) => new Set(prev).add(note.id))
+
+ setTimeout(() => {
+ setNewlyRenamedNodeIds((prev) => {
+ const next = new Set(prev)
+ next.delete(note.id)
+ return next
+ })
+ }, 700)
+ }
+ },
+ [onRenameNode, t]
+ )
+
+ return {
+ editingNodeId,
+ renamingNodeIds,
+ newlyRenamedNodeIds,
+ inPlaceEdit,
+ handleStartEdit,
+ handleAutoRename,
+ setEditingNodeId
+ }
+}
diff --git a/src/renderer/src/pages/notes/hooks/useNotesFileUpload.ts b/src/renderer/src/pages/notes/hooks/useNotesFileUpload.ts
new file mode 100644
index 0000000000..aba1a90992
--- /dev/null
+++ b/src/renderer/src/pages/notes/hooks/useNotesFileUpload.ts
@@ -0,0 +1,112 @@
+import { useCallback } from 'react'
+
+interface UseNotesFileUploadProps {
+ onUploadFiles: (files: File[]) => void
+ setIsDragOverSidebar: (isDragOver: boolean) => void
+}
+
+export const useNotesFileUpload = ({ onUploadFiles, setIsDragOverSidebar }: UseNotesFileUploadProps) => {
+ const handleDropFiles = useCallback(
+ async (e: React.DragEvent) => {
+ e.preventDefault()
+ setIsDragOverSidebar(false)
+
+ // 处理文件夹拖拽:从 dataTransfer.items 获取完整文件路径信息
+ const items = Array.from(e.dataTransfer.items)
+ const files: File[] = []
+
+ const processEntry = async (entry: FileSystemEntry, path: string = '') => {
+ if (entry.isFile) {
+ const fileEntry = entry as FileSystemFileEntry
+ return new Promise((resolve) => {
+ fileEntry.file((file) => {
+ // 手动设置 webkitRelativePath 以保持文件夹结构
+ Object.defineProperty(file, 'webkitRelativePath', {
+ value: path + file.name,
+ writable: false
+ })
+ files.push(file)
+ resolve()
+ })
+ })
+ } else if (entry.isDirectory) {
+ const dirEntry = entry as FileSystemDirectoryEntry
+ const reader = dirEntry.createReader()
+ return new Promise((resolve) => {
+ reader.readEntries(async (entries) => {
+ const promises = entries.map((subEntry) => processEntry(subEntry, path + entry.name + '/'))
+ await Promise.all(promises)
+ resolve()
+ })
+ })
+ }
+ }
+
+ // 如果支持 DataTransferItem API(文件夹拖拽)
+ if (items.length > 0 && items[0].webkitGetAsEntry()) {
+ const promises = items.map((item) => {
+ const entry = item.webkitGetAsEntry()
+ return entry ? processEntry(entry) : Promise.resolve()
+ })
+
+ await Promise.all(promises)
+
+ if (files.length > 0) {
+ onUploadFiles(files)
+ }
+ } else {
+ const regularFiles = Array.from(e.dataTransfer.files)
+ if (regularFiles.length > 0) {
+ onUploadFiles(regularFiles)
+ }
+ }
+ },
+ [onUploadFiles, setIsDragOverSidebar]
+ )
+
+ const handleSelectFiles = useCallback(() => {
+ const fileInput = document.createElement('input')
+ fileInput.type = 'file'
+ fileInput.multiple = true
+ fileInput.accept = '.md,.markdown'
+ fileInput.webkitdirectory = false
+
+ fileInput.onchange = (e) => {
+ const target = e.target as HTMLInputElement
+ if (target.files && target.files.length > 0) {
+ const selectedFiles = Array.from(target.files)
+ onUploadFiles(selectedFiles)
+ }
+ fileInput.remove()
+ }
+
+ fileInput.click()
+ }, [onUploadFiles])
+
+ const handleSelectFolder = useCallback(() => {
+ const folderInput = document.createElement('input')
+ folderInput.type = 'file'
+ // @ts-ignore - webkitdirectory is a non-standard attribute
+ folderInput.webkitdirectory = true
+ // @ts-ignore - directory is a non-standard attribute
+ folderInput.directory = true
+ folderInput.multiple = true
+
+ folderInput.onchange = (e) => {
+ const target = e.target as HTMLInputElement
+ if (target.files && target.files.length > 0) {
+ const selectedFiles = Array.from(target.files)
+ onUploadFiles(selectedFiles)
+ }
+ folderInput.remove()
+ }
+
+ folderInput.click()
+ }, [onUploadFiles])
+
+ return {
+ handleDropFiles,
+ handleSelectFiles,
+ handleSelectFolder
+ }
+}
diff --git a/src/renderer/src/pages/notes/hooks/useNotesMenu.tsx b/src/renderer/src/pages/notes/hooks/useNotesMenu.tsx
new file mode 100644
index 0000000000..f08f9b1505
--- /dev/null
+++ b/src/renderer/src/pages/notes/hooks/useNotesMenu.tsx
@@ -0,0 +1,263 @@
+import { loggerService } from '@logger'
+import { DeleteIcon } from '@renderer/components/Icons'
+import SaveToKnowledgePopup from '@renderer/components/Popups/SaveToKnowledgePopup'
+import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
+import type { RootState } from '@renderer/store'
+import type { NotesTreeNode } from '@renderer/types/note'
+import { exportNote } from '@renderer/utils/export'
+import type { MenuProps } from 'antd'
+import type { ItemType, MenuItemType } from 'antd/es/menu/interface'
+import { Edit3, FilePlus, FileSearch, Folder, FolderOpen, Sparkles, Star, StarOff, UploadIcon } from 'lucide-react'
+import { useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useSelector } from 'react-redux'
+
+const logger = loggerService.withContext('UseNotesMenu')
+
+interface UseNotesMenuProps {
+ renamingNodeIds: Set
+ onCreateNote: (name: string, targetFolderId?: string) => void
+ onCreateFolder: (name: string, targetFolderId?: string) => void
+ onRenameNode: (nodeId: string, newName: string) => void
+ onToggleStar: (nodeId: string) => void
+ onDeleteNode: (nodeId: string) => void
+ onSelectNode: (node: NotesTreeNode) => void
+ handleStartEdit: (node: NotesTreeNode) => void
+ handleAutoRename: (node: NotesTreeNode) => void
+ activeNode?: NotesTreeNode | null
+}
+
+export const useNotesMenu = ({
+ renamingNodeIds,
+ onCreateNote,
+ onCreateFolder,
+ onToggleStar,
+ onDeleteNode,
+ onSelectNode,
+ handleStartEdit,
+ handleAutoRename,
+ activeNode
+}: UseNotesMenuProps) => {
+ const { t } = useTranslation()
+ const { bases } = useKnowledgeBases()
+ const exportMenuOptions = useSelector((state: RootState) => state.settings.exportMenuOptions)
+
+ const handleExportKnowledge = useCallback(
+ async (note: NotesTreeNode) => {
+ try {
+ if (bases.length === 0) {
+ window.toast.warning(t('chat.save.knowledge.empty.no_knowledge_base'))
+ return
+ }
+
+ const result = await SaveToKnowledgePopup.showForNote(note)
+
+ if (result?.success) {
+ window.toast.success(t('notes.export_success', { count: result.savedCount }))
+ }
+ } catch (error) {
+ window.toast.error(t('notes.export_failed'))
+ logger.error(`Failed to export note to knowledge base: ${error}`)
+ }
+ },
+ [bases.length, t]
+ )
+
+ const handleImageAction = useCallback(
+ async (node: NotesTreeNode, platform: 'copyImage' | 'exportImage') => {
+ try {
+ if (activeNode?.id !== node.id) {
+ onSelectNode(node)
+ await new Promise((resolve) => setTimeout(resolve, 500))
+ }
+
+ await exportNote({ node, platform })
+ } catch (error) {
+ logger.error(`Failed to ${platform === 'copyImage' ? 'copy' : 'export'} as image:`, error as Error)
+ window.toast.error(t('common.copy_failed'))
+ }
+ },
+ [activeNode, onSelectNode, t]
+ )
+
+ const handleDeleteNodeWrapper = useCallback(
+ (node: NotesTreeNode) => {
+ const confirmText =
+ node.type === 'folder'
+ ? t('notes.delete_folder_confirm', { name: node.name })
+ : t('notes.delete_note_confirm', { name: node.name })
+
+ window.modal.confirm({
+ title: t('notes.delete'),
+ content: confirmText,
+ centered: true,
+ okButtonProps: { danger: true },
+ onOk: () => {
+ onDeleteNode(node.id)
+ }
+ })
+ },
+ [onDeleteNode, t]
+ )
+
+ const getMenuItems = useCallback(
+ (node: NotesTreeNode) => {
+ const baseMenuItems: MenuProps['items'] = []
+
+ // only show auto rename for file for now
+ if (node.type !== 'folder') {
+ baseMenuItems.push({
+ label: t('notes.auto_rename.label'),
+ key: 'auto-rename',
+ icon: ,
+ disabled: renamingNodeIds.has(node.id),
+ onClick: () => {
+ handleAutoRename(node)
+ }
+ })
+ }
+
+ if (node.type === 'folder') {
+ baseMenuItems.push(
+ {
+ label: t('notes.new_note'),
+ key: 'new_note',
+ icon: ,
+ onClick: () => {
+ onCreateNote(t('notes.untitled_note'), node.id)
+ }
+ },
+ {
+ label: t('notes.new_folder'),
+ key: 'new_folder',
+ icon: ,
+ onClick: () => {
+ onCreateFolder(t('notes.untitled_folder'), node.id)
+ }
+ },
+ { type: 'divider' }
+ )
+ }
+
+ baseMenuItems.push(
+ {
+ label: t('notes.rename'),
+ key: 'rename',
+ icon: ,
+ onClick: () => {
+ handleStartEdit(node)
+ }
+ },
+ {
+ label: t('notes.open_outside'),
+ key: 'open_outside',
+ icon: ,
+ onClick: () => {
+ window.api.openPath(node.externalPath)
+ }
+ }
+ )
+ if (node.type !== 'folder') {
+ baseMenuItems.push(
+ {
+ label: node.isStarred ? t('notes.unstar') : t('notes.star'),
+ key: 'star',
+ icon: node.isStarred ? : ,
+ onClick: () => {
+ onToggleStar(node.id)
+ }
+ },
+ {
+ label: t('notes.export_knowledge'),
+ key: 'export_knowledge',
+ icon: ,
+ onClick: () => {
+ handleExportKnowledge(node)
+ }
+ },
+ {
+ label: t('chat.topics.export.title'),
+ key: 'export',
+ icon: ,
+ children: [
+ exportMenuOptions.image && {
+ label: t('chat.topics.copy.image'),
+ key: 'copy-image',
+ onClick: () => handleImageAction(node, 'copyImage')
+ },
+ exportMenuOptions.image && {
+ label: t('chat.topics.export.image'),
+ key: 'export-image',
+ onClick: () => handleImageAction(node, 'exportImage')
+ },
+ exportMenuOptions.markdown && {
+ label: t('chat.topics.export.md.label'),
+ key: 'markdown',
+ onClick: () => exportNote({ node, platform: 'markdown' })
+ },
+ exportMenuOptions.docx && {
+ label: t('chat.topics.export.word'),
+ key: 'word',
+ onClick: () => exportNote({ node, platform: 'docx' })
+ },
+ exportMenuOptions.notion && {
+ label: t('chat.topics.export.notion'),
+ key: 'notion',
+ onClick: () => exportNote({ node, platform: 'notion' })
+ },
+ exportMenuOptions.yuque && {
+ label: t('chat.topics.export.yuque'),
+ key: 'yuque',
+ onClick: () => exportNote({ node, platform: 'yuque' })
+ },
+ exportMenuOptions.obsidian && {
+ label: t('chat.topics.export.obsidian'),
+ key: 'obsidian',
+ onClick: () => exportNote({ node, platform: 'obsidian' })
+ },
+ exportMenuOptions.joplin && {
+ label: t('chat.topics.export.joplin'),
+ key: 'joplin',
+ onClick: () => exportNote({ node, platform: 'joplin' })
+ },
+ exportMenuOptions.siyuan && {
+ label: t('chat.topics.export.siyuan'),
+ key: 'siyuan',
+ onClick: () => exportNote({ node, platform: 'siyuan' })
+ }
+ ].filter(Boolean) as ItemType[]
+ }
+ )
+ }
+ baseMenuItems.push(
+ { type: 'divider' },
+ {
+ label: t('notes.delete'),
+ danger: true,
+ key: 'delete',
+ icon: ,
+ onClick: () => {
+ handleDeleteNodeWrapper(node)
+ }
+ }
+ )
+
+ return baseMenuItems
+ },
+ [
+ t,
+ handleStartEdit,
+ onToggleStar,
+ handleExportKnowledge,
+ handleImageAction,
+ handleDeleteNodeWrapper,
+ renamingNodeIds,
+ handleAutoRename,
+ exportMenuOptions,
+ onCreateNote,
+ onCreateFolder
+ ]
+ )
+
+ return { getMenuItems }
+}
diff --git a/src/renderer/src/types/note.ts b/src/renderer/src/types/note.ts
index fda85e63d8..83bbd74e5d 100644
--- a/src/renderer/src/types/note.ts
+++ b/src/renderer/src/types/note.ts
@@ -13,7 +13,7 @@ export type NotesSortType =
export interface NotesTreeNode {
id: string
name: string // 不包含扩展名
- type: 'folder' | 'file'
+ type: 'folder' | 'file' | 'hint'
treePath: string // 相对路径
externalPath: string // 绝对路径
children?: NotesTreeNode[]
diff --git a/yarn.lock b/yarn.lock
index 18bde2062e..f6c5315835 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8744,12 +8744,12 @@ __metadata:
languageName: node
linkType: hard
-"@types/react-dom@npm:^19.0.4":
- version: 19.1.2
- resolution: "@types/react-dom@npm:19.1.2"
+"@types/react-dom@npm:^19.2.3":
+ version: 19.2.3
+ resolution: "@types/react-dom@npm:19.2.3"
peerDependencies:
- "@types/react": ^19.0.0
- checksum: 10c0/100c341cacba9ec8ae1d47ee051072a3450e9573bf8eeb7262490e341cb246ea0f95a07a1f2077e61cf92648f812a0324c602fcd811bd87b7ce41db2811510cd
+ "@types/react": ^19.2.0
+ checksum: 10c0/b486ebe0f4e2fb35e2e108df1d8fc0927ca5d6002d5771e8a739de11239fe62d0e207c50886185253c99eb9dedfeeb956ea7429e5ba17f6693c7acb4c02f8cd1
languageName: node
linkType: hard
@@ -8789,12 +8789,12 @@ __metadata:
languageName: node
linkType: hard
-"@types/react@npm:^19.0.12":
- version: 19.1.2
- resolution: "@types/react@npm:19.1.2"
+"@types/react@npm:^19.2.6":
+ version: 19.2.6
+ resolution: "@types/react@npm:19.2.6"
dependencies:
- csstype: "npm:^3.0.2"
- checksum: 10c0/76ffe71395c713d4adc3c759465012d3c956db00af35ab7c6d0d91bd07b274b7ce69caa0478c0760311587bd1e38c78ffc9688ebc629f2b266682a19d8750947
+ csstype: "npm:^3.2.2"
+ checksum: 10c0/23b1100f88662ce9f9e4fcca3a2b4ef9fff1ecde24ede2b2dcbd07731e48d6946fd7fd156cd133f5b25321694b0569cd9b8dd30b22c4e076d1cf4c8cdd9a75cb
languageName: node
linkType: hard
@@ -10015,8 +10015,8 @@ __metadata:
"@types/mime-types": "npm:^3"
"@types/node": "npm:^22.17.1"
"@types/pako": "npm:^1.0.2"
- "@types/react": "npm:^19.0.12"
- "@types/react-dom": "npm:^19.0.4"
+ "@types/react": "npm:^19.2.6"
+ "@types/react-dom": "npm:^19.2.3"
"@types/react-infinite-scroll-component": "npm:^5.0.0"
"@types/react-transition-group": "npm:^4.4.12"
"@types/react-window": "npm:^1"
@@ -12283,6 +12283,13 @@ __metadata:
languageName: node
linkType: hard
+"csstype@npm:^3.2.2":
+ version: 3.2.3
+ resolution: "csstype@npm:3.2.3"
+ checksum: 10c0/cd29c51e70fa822f1cecd8641a1445bed7063697469d35633b516e60fe8c1bde04b08f6c5b6022136bb669b64c63d4173af54864510fbb4ee23281801841a3ce
+ languageName: node
+ linkType: hard
+
"csv-parse@npm:^5.6.0":
version: 5.6.0
resolution: "csv-parse@npm:5.6.0"