Add FileSelector to CodeEditor
This commit is contained in:
114
src/components/ReactPlayground/CodeEditor/FileSelector/Item.tsx
Normal file
114
src/components/ReactPlayground/CodeEditor/FileSelector/Item.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React from 'react'
|
||||
|
||||
interface ItemProps {
|
||||
readonly?: boolean
|
||||
creating?: boolean
|
||||
value: string
|
||||
active?: boolean
|
||||
onOk?: (fileName: string) => void
|
||||
onCancel?: () => void
|
||||
onRemove?: (fileName: string) => void
|
||||
onClick?: () => void
|
||||
onValidate?: (newFileName: string, oldFileName: string) => boolean
|
||||
}
|
||||
|
||||
const Item: React.FC<ItemProps> = ({
|
||||
readonly = false,
|
||||
value,
|
||||
active = false,
|
||||
onOk,
|
||||
onCancel,
|
||||
onRemove,
|
||||
onClick,
|
||||
onValidate,
|
||||
...prop
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [fileName, setFileName] = useState(value)
|
||||
const [creating, setCreating] = useState(prop.creating)
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
finishNameFile()
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
cancelNameFile()
|
||||
}
|
||||
}
|
||||
|
||||
const finishNameFile = () => {
|
||||
if (!creating || onValidate ? !onValidate?.(fileName, value) : false) {
|
||||
return
|
||||
}
|
||||
|
||||
if (fileName === value && active) {
|
||||
setCreating(false)
|
||||
return
|
||||
}
|
||||
|
||||
onOk?.(fileName)
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
const cancelNameFile = () => {
|
||||
setFileName(value)
|
||||
setCreating(false)
|
||||
onCancel?.()
|
||||
}
|
||||
|
||||
const handleOnDoubleClick = () => {
|
||||
if (readonly) {
|
||||
return
|
||||
}
|
||||
|
||||
setCreating(true)
|
||||
setFileName(value)
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFileName(e.target.value)
|
||||
}
|
||||
|
||||
const handleOnDelete = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation()
|
||||
if (confirm(`确定删除文件 ${value} ?`)) {
|
||||
onRemove?.(value)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={`tab-item${active ? ' active' : ''}`} onClick={onClick}>
|
||||
{creating ? (
|
||||
<div className={'tab-item-input'}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={fileName}
|
||||
onChange={handleOnChange}
|
||||
onBlur={finishNameFile}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<span className={'tab-item-input-mask'}>{fileName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div onDoubleClick={handleOnDoubleClick}>{value}</div>
|
||||
{!readonly && (
|
||||
<div className={'tab-item-close'} onClick={handleOnDelete}>
|
||||
<IconOxygenClose />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Item
|
||||
@@ -0,0 +1,54 @@
|
||||
[data-component=playground-file-selector].tab{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1px;
|
||||
padding: 10px 20px 0 20px;
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 30px;
|
||||
padding: 0 20px;
|
||||
border: 1px solid #f0f0f0;
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
border-radius: 6px 6px 0 0;
|
||||
cursor: pointer;
|
||||
|
||||
.tab-item-input {
|
||||
position: relative;
|
||||
min-width: 40px;
|
||||
transform: translateY(1px);
|
||||
|
||||
.tab-item-input-mask {
|
||||
display: inline-block;
|
||||
color: transparent;
|
||||
}
|
||||
input {
|
||||
position: absolute;
|
||||
background-color: transparent;
|
||||
width: 100%;
|
||||
font-size: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-item-close {
|
||||
transform: translateX(10px);
|
||||
|
||||
:hover {
|
||||
fill: #888;
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 8px;
|
||||
fill: #666;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: white;
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
151
src/components/ReactPlayground/CodeEditor/FileSelector/index.tsx
Normal file
151
src/components/ReactPlayground/CodeEditor/FileSelector/index.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React from 'react'
|
||||
import '@/components/ReactPlayground/CodeEditor/FileSelector/file-selector.scss'
|
||||
import { IFiles } from '@/components/ReactPlayground/shared.ts'
|
||||
import {
|
||||
ENTRY_FILE_NAME,
|
||||
IMPORT_MAP_FILE_NAME,
|
||||
MAIN_FILE_NAME
|
||||
} from '@/components/ReactPlayground/files.ts'
|
||||
import Item from '@/components/ReactPlayground/CodeEditor/FileSelector/Item.tsx'
|
||||
|
||||
interface FileSelectorProps {
|
||||
files?: IFiles
|
||||
onChange?: (fileName: string) => void
|
||||
onError?: (msg: string) => void
|
||||
readonly?: boolean
|
||||
readonlyFiles?: string[]
|
||||
onRemoveFile?: (fileName: string) => void
|
||||
onAddFile?: (fileName: string) => void
|
||||
onUpdateFileName?: (newFileName: string, oldFileName: string) => void
|
||||
selectedFileName?: string
|
||||
}
|
||||
|
||||
const FileSelector: React.FC<FileSelectorProps> = ({
|
||||
files = {},
|
||||
onChange,
|
||||
onError,
|
||||
readonly = false,
|
||||
readonlyFiles = [],
|
||||
onRemoveFile,
|
||||
onAddFile,
|
||||
onUpdateFileName,
|
||||
selectedFileName = ''
|
||||
}) => {
|
||||
const [tabs, setTabs] = useState<string[]>([])
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const getMaxSequenceTabName = (filesName: string[]) => {
|
||||
const result = filesName.reduce((max, filesName) => {
|
||||
const match = filesName.match(/Component(\d+)\.tsx/)
|
||||
if (match) {
|
||||
const sequenceNumber = parseInt(match[1], 10)
|
||||
return Math.max(Number(max), sequenceNumber)
|
||||
}
|
||||
return max
|
||||
}, 0)
|
||||
|
||||
return `Component${result + 1}.tsx`
|
||||
}
|
||||
|
||||
const addTab = () => {
|
||||
setTabs([...tabs, getMaxSequenceTabName(tabs)])
|
||||
setCreating(true)
|
||||
}
|
||||
|
||||
const handleOnCancel = () => {
|
||||
if (!creating) {
|
||||
return
|
||||
}
|
||||
|
||||
tabs.pop()
|
||||
setTabs([...tabs])
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
const handleOnClickTab = (fileName: string) => {
|
||||
if (creating) {
|
||||
return
|
||||
}
|
||||
|
||||
onChange?.(fileName)
|
||||
}
|
||||
|
||||
const editImportMap = () => {
|
||||
onChange?.(IMPORT_MAP_FILE_NAME)
|
||||
}
|
||||
|
||||
const handleOnSaveTab = (value: string, item: string) => {
|
||||
if (creating) {
|
||||
onAddFile?.(value)
|
||||
setCreating(false)
|
||||
} else {
|
||||
onUpdateFileName?.(item, value)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
handleOnClickTab(value)
|
||||
})
|
||||
}
|
||||
|
||||
const handleOnValidateTab = (newFileName: string, oldFileName: string) => {
|
||||
if (!/\.(jsx|tsx|js|ts|css|json|svg)$/.test(newFileName)) {
|
||||
onError?.(
|
||||
'Playground only supports *.jsx, *.tsx, *.js, *.ts, *.css, *.json, *.svg files.'
|
||||
)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
if (tabs.includes(newFileName) && newFileName !== oldFileName) {
|
||||
onError?.(`File "${newFileName}" already exists.`)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const handleOnRemove = (fileName: string) => {
|
||||
onRemoveFile?.(fileName)
|
||||
handleOnClickTab(Object.keys(files)[Object.keys(files).length - 1])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
Object.keys(files).length &&
|
||||
setTabs(
|
||||
Object.keys(files).filter(
|
||||
(item) =>
|
||||
![IMPORT_MAP_FILE_NAME, ENTRY_FILE_NAME].includes(item) &&
|
||||
!files[item].hidden
|
||||
)
|
||||
)
|
||||
}, [files])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-component={'playground-file-selector'}
|
||||
className={'tab'}
|
||||
style={{ flex: '0 0 auto' }}
|
||||
>
|
||||
{tabs.map((item, index) => (
|
||||
<Item
|
||||
key={index + item}
|
||||
value={item}
|
||||
active={selectedFileName == item}
|
||||
creating={creating}
|
||||
readonly={
|
||||
readonly || readonlyFiles.includes(item) || MAIN_FILE_NAME == item
|
||||
}
|
||||
onValidate={handleOnValidateTab}
|
||||
onOk={(name) => handleOnSaveTab(name, item)}
|
||||
onCancel={handleOnCancel}
|
||||
onRemove={handleOnRemove}
|
||||
onClick={() => handleOnClickTab(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileSelector
|
||||
Reference in New Issue
Block a user