Skip to content

Commit 54ba1ec

Browse files
committed
feat: file tree context menu
1 parent 81f5a65 commit 54ba1ec

File tree

13 files changed

+461
-92
lines changed

13 files changed

+461
-92
lines changed

apps/linebyline/src/components/Explorer/index.tsx

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { useGlobalCacheData, useOpen } from '@/hooks'
1010
import { CacheManager } from '@/helper'
1111
import { Empty, FileTree, List, Popper } from '@/components'
1212
import styled from 'styled-components'
13-
import type { RightNavItem } from '../SideBar/SideBarHeader'
1413
import SideBarHeader from '../SideBar/SideBarHeader'
1514

1615
const RecentListBottom = styled.div`
@@ -26,7 +25,7 @@ const RecentListBottom = styled.div`
2625

2726
const Explorer: FC<ExplorerProps> = (props) => {
2827
const { t } = useTranslation()
29-
const { addFile, folderData, activeId, addOpenedFile, setActiveId } = useEditorStore()
28+
const { folderData, activeId, addOpenedFile, setActiveId } = useEditorStore()
3029
const [popperOpen, setPopperOpen] = useState(false)
3130
const [cache] = useGlobalCacheData()
3231
const { openFolderDialog, openFolder } = useOpen()
@@ -51,15 +50,6 @@ const Explorer: FC<ExplorerProps> = (props) => {
5150
[openFolder],
5251
)
5352

54-
const handleRightNavItemClick = useCallback(
55-
(item: RightNavItem) => {
56-
if (item.key === 'addFile') {
57-
addFile()
58-
}
59-
},
60-
[addFile],
61-
)
62-
6353
const listData = useMemo(
6454
() =>
6555
cache.openFolderHistory.map((history: { time: string; path: string }) => ({
@@ -74,19 +64,9 @@ const Explorer: FC<ExplorerProps> = (props) => {
7464

7565
return (
7666
<Container className={containerCLs}>
77-
<SideBarHeader
78-
name='EXPLORER'
79-
onRightNavItemClick={handleRightNavItemClick}
80-
rightNavItems={[
81-
{
82-
iconCls: 'ri-file-add-line',
83-
key: 'addFile',
84-
tooltip: { title: 'Add File', arrow: true },
85-
},
86-
]}
87-
/>
67+
<SideBarHeader name='EXPLORER' />
8868
<div className='h-full w-full overflow-auto'>
89-
{folderData && folderData.length > 1 ? (
69+
{folderData && folderData.length > 0 ? (
9070
<FileTree
9171
className='flex-1'
9272
data={folderData}

apps/linebyline/src/components/FileTree/FileNode.tsx

Lines changed: 41 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,53 @@
11
import classNames from 'classnames'
22
import type { FC, MouseEventHandler } from 'react'
3-
import { memo, useCallback, useState } from 'react'
3+
import { memo, useCallback, useEffect, useRef, useState } from 'react'
44
import { FileNodeStyled } from './styles'
55
import type { IFile } from '@/helper/filesys'
6+
import type { NewInputRef } from './NewFIleInput'
7+
import NewFileInput from './NewFIleInput'
8+
import bus from '@/helper/eventBus'
9+
import useFileTreeContextMenuNode from '@/hooks/useContextMenuNode'
610

7-
const FileNode: FC<FileNodeProps> = ({
8-
item,
9-
level = 0,
10-
activeId,
11-
onSelect,
12-
}) => {
11+
const FileNode: FC<FileNodeProps> = ({ item, level = 0, activeId, onSelect }) => {
1312
const [isOpen, setIsOpen] = useState(false)
13+
const newInputRef = useRef<NewInputRef>(null)
14+
const { contextMenuNode, setContextMenuNode } = useFileTreeContextMenuNode()
1415
const isActived = activeId === item.id
1516
const isFolder = item.kind === 'dir'
1617

18+
useEffect(() => {
19+
const newFileHandler = () => {
20+
if (!contextMenuNode || contextMenuNode.id !== item.id) return
21+
newInputRef.current?.show({ fileNode: contextMenuNode })
22+
}
23+
24+
bus.on('SIDEBAR:show-new-input', newFileHandler)
25+
26+
return () => {
27+
bus.detach('SIDEBAR:show-new-input', newFileHandler)
28+
}
29+
}, [contextMenuNode, item])
30+
1731
const handleClick: MouseEventHandler = useCallback(
1832
(e) => {
1933
e.stopPropagation()
34+
bus.emit('SIDEBAR:hide-new-input')
2035
setIsOpen(!isOpen)
2136
},
2237
[isOpen],
2338
)
2439

25-
const handleSelect: MouseEventHandler = useCallback(
26-
() => {
27-
onSelect(item)
40+
const handleSelect: MouseEventHandler = useCallback(() => {
41+
onSelect(item)
42+
}, [item, onSelect])
43+
44+
const handleContextMenu = useCallback(
45+
(e: React.MouseEvent<HTMLDivElement, MouseEvent>, fileNode?: IFile) => {
46+
e.stopPropagation()
47+
const node = fileNode || item
48+
setContextMenuNode(node)
2849
},
29-
[item, onSelect],
50+
[item, setContextMenuNode],
3051
)
3152

3253
const nodeWrapperCls = classNames('file-node', {
@@ -36,24 +57,23 @@ const FileNode: FC<FileNodeProps> = ({
3657
const iconCls = 'file-icon'
3758

3859
return (
39-
<FileNodeStyled onClick={handleClick}>
60+
<FileNodeStyled onClick={handleClick} onContextMenu={handleContextMenu}>
61+
<NewFileInput ref={newInputRef} autoFocus style={{ marginLeft: level * 16 + 6 }} />
4062
<div
4163
className={nodeWrapperCls}
4264
style={{ paddingLeft: level * 16 + 6 }}
4365
onClick={handleSelect}
4466
>
45-
{isFolder
46-
? (
67+
{isFolder ? (
4768
<i className={`${isOpen ? 'ri-folder-5-line' : 'ri-folder-3-line'} ${iconCls}`} />
48-
)
49-
: (
69+
) : (
5070
<i className={`ri-markdown-fill ${iconCls}`} />
51-
)}
52-
<div className="file-node__text">{item.name}</div>
71+
)}
72+
<div className='file-node__text'>{item.name}</div>
5373
</div>
54-
{isOpen
55-
&& item.children
56-
&& item.children.map(child => (
74+
{isOpen &&
75+
item.children &&
76+
item.children.map((child) => (
5777
<FileNode
5878
key={child.name}
5979
item={child}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import bus from '@/helper/eventBus'
2+
import useFileTreeContextMenu from '@/hooks/useContextMenu'
3+
import { MenuList, MenuItem, ListItemText, Paper } from '@mui/material'
4+
import { memo } from 'react'
5+
import styled, { css } from 'styled-components'
6+
7+
const ContextMenu = styled(Paper)<ContextMenuProps>`
8+
position: fixed;
9+
width: 140px;
10+
11+
${({ top, left }) => css`
12+
top: ${top}px;
13+
left: ${left}px;
14+
`}
15+
`
16+
17+
export const FileNodeContextMenu = memo((props: ContextMenuProps) => {
18+
const { open, setOpen } = useFileTreeContextMenu()
19+
const { ...positionProps } = props
20+
21+
if (!open) return null
22+
23+
const ContextMenuItems = [
24+
{
25+
key: 'add_file',
26+
label: 'Add File',
27+
handler: () => {
28+
bus.emit('SIDEBAR:show-new-input')
29+
},
30+
},
31+
]
32+
33+
return (
34+
<ContextMenu {...positionProps}>
35+
<MenuList sx={{ padding: 0 }}>
36+
{ContextMenuItems.map((menuItem) => {
37+
const { key, label, handler } = menuItem
38+
return (
39+
<MenuItem
40+
sx={{ padding: 0.7 }}
41+
key={key}
42+
onClick={(e) => {
43+
e.stopPropagation()
44+
setOpen(false)
45+
handler()
46+
}}
47+
>
48+
<ListItemText>
49+
<span style={{ fontSize: 12 }}>{label}</span>
50+
</ListItemText>
51+
</MenuItem>
52+
)
53+
})}
54+
</MenuList>
55+
</ContextMenu>
56+
)
57+
})
58+
59+
interface ContextMenuProps {
60+
top: number
61+
left: number
62+
}

apps/linebyline/src/components/FileTree/FileTree.tsx

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,35 @@
11
import classNames from 'classnames'
22
import type { FC } from 'react'
3-
import { memo } from 'react'
3+
import { memo, useCallback } from 'react'
44
import FileNode from './FileNode'
55
import type { IFile } from '@/helper/filesys'
6+
import { FileNodeContextMenu } from './FileNodeContextMenu'
7+
import useFileTreeContextMenu from '@/hooks/useContextMenu'
68

79
const FileTree: FC<FileTreeProps> = (props) => {
810
const { data, activeId, onSelect, className } = props
11+
const { setOpen, points, setPoints } = useFileTreeContextMenu()
912

1013
const containerCls = classNames('w-full overflow-hidden', className)
1114

15+
const handleContextMenu = useCallback(
16+
(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
17+
e.preventDefault()
18+
setOpen(true)
19+
setPoints({
20+
x: e.clientX,
21+
y: e.clientY,
22+
})
23+
},
24+
[setOpen, setPoints],
25+
)
26+
1227
return (
13-
<div className={containerCls}>
14-
{data?.map(item => (
15-
<FileNode
16-
key={item.name}
17-
item={item}
18-
level={0}
19-
activeId={activeId}
20-
onSelect={onSelect}
21-
/>
28+
<div className={containerCls} onContextMenuCapture={handleContextMenu}>
29+
{data?.map((item) => (
30+
<FileNode key={item.name} item={item} level={0} activeId={activeId} onSelect={onSelect} />
2231
))}
32+
<FileNodeContextMenu top={points.y} left={points.x} />
2333
</div>
2434
)
2535
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import bus from '@/helper/eventBus'
2+
import type { IFile } from '@/helper/filesys'
3+
import { useEditorStore } from '@/stores'
4+
import {
5+
useCallback,
6+
type HTMLAttributes,
7+
useState,
8+
forwardRef,
9+
useImperativeHandle,
10+
useRef,
11+
useEffect,
12+
} from 'react'
13+
14+
export type NewInputRef = {
15+
show: (args: { fileNode: IFile }) => void
16+
}
17+
18+
const NewFileInput = forwardRef<NewInputRef, HTMLAttributes<HTMLInputElement>>((props, ref) => {
19+
const [visible, setVisible] = useState(false)
20+
const [inputName, setInputName] = useState('')
21+
const contextFileNode = useRef<IFile>()
22+
const { addFile } = useEditorStore()
23+
24+
const hideInput = useCallback( () => {
25+
setVisible(false)
26+
setInputName('')
27+
}, [])
28+
29+
useEffect(() => {
30+
if (visible) {
31+
setTimeout(() => {
32+
document.addEventListener('click', hideInput)
33+
})
34+
}
35+
36+
return () => {
37+
document.removeEventListener('click', hideInput)
38+
}
39+
}, [visible, hideInput])
40+
41+
useEffect(() => {
42+
bus.on('SIDEBAR:hide-new-input', hideInput)
43+
44+
return () => {
45+
bus.detach('SIDEBAR:hide-new-input', hideInput)
46+
}
47+
}, [hideInput])
48+
49+
useImperativeHandle(ref, () => ({
50+
show({ fileNode }) {
51+
setVisible(true)
52+
contextFileNode.current = fileNode
53+
},
54+
}))
55+
56+
const handleKeyup: React.KeyboardEventHandler<HTMLInputElement> = useCallback(
57+
(event) => {
58+
if (!event.shiftKey && event.keyCode === 13) {
59+
if (!contextFileNode.current) return
60+
const res = addFile(contextFileNode.current, { name: inputName, kind: 'file' })
61+
62+
if (res === false) {
63+
// TODO has same file
64+
}
65+
66+
hideInput()
67+
event.preventDefault()
68+
return false
69+
}
70+
},
71+
[addFile, inputName, hideInput],
72+
)
73+
74+
const handleChange: React.ChangeEventHandler<HTMLInputElement> = useCallback((e) => {
75+
setInputName(e.target.value)
76+
}, [])
77+
78+
if (!visible) return null
79+
80+
return (
81+
<input
82+
className='newfile-input'
83+
value={inputName}
84+
onChange={handleChange}
85+
onKeyUp={handleKeyup}
86+
{...props}
87+
></input>
88+
)
89+
})
90+
91+
export default NewFileInput

apps/linebyline/src/components/FileTree/styles.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const FileNodeStyled = styled.div`
99
box-sizing: border-box;
1010
font-size: 0.9rem;
1111
cursor: pointer;
12+
user-select: none;
1213
1314
&__text {
1415
text-overflow: ellipsis;
@@ -30,4 +31,10 @@ export const FileNodeStyled = styled.div`
3031
.file-icon {
3132
flex-shrink: 0;
3233
}
34+
35+
36+
.newfile-input {
37+
margin: 0 8px;
38+
border: 1px solid ${props => props.theme.accentColor};
39+
}
3340
`

0 commit comments

Comments
 (0)