Add FileSelector to CodeEditor
This commit is contained in:
1
src/assets/svg/close.svg
Normal file
1
src/assets/svg/close.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1028 1024"><path d="M646.4 512l345.6-345.6c38.4-38.4 38.4-96 0-134.4-38.4-38.4-96-38.4-134.4 0L512 377.6 166.4 32C128-6.4 70.4-6.4 32 32c-38.4 38.4-38.4 96 0 134.4L377.6 512l-345.6 345.6c-38.4 38.4-38.4 96 0 134.4 19.2 19.2 44.8 25.6 70.4 25.6s51.2-6.4 70.4-25.6L512 646.4l345.6 345.6c19.2 19.2 44.8 25.6 70.4 25.6s51.2-6.4 70.4-25.6c38.4-38.4 38.4-96 0-134.4L646.4 512z" /></svg>
|
||||||
|
After Width: | Height: | Size: 433 B |
@@ -18,32 +18,47 @@ const delegateListener = createDelegate()
|
|||||||
|
|
||||||
type InferSet<T> = T extends Set<infer U> ? U : never
|
type InferSet<T> = T extends Set<infer U> ? U : never
|
||||||
|
|
||||||
export const createATA = async () => {
|
export interface TypeHelper {
|
||||||
// @ts-ignore
|
dispose: () => void
|
||||||
|
acquireType: (code: string) => void
|
||||||
|
removeListener: <T extends keyof DelegateListener>(
|
||||||
|
event: T,
|
||||||
|
handler: InferSet<DelegateListener[T]>
|
||||||
|
) => void
|
||||||
|
addListener: <T extends keyof DelegateListener>(
|
||||||
|
event: T,
|
||||||
|
handler: InferSet<DelegateListener[T]>
|
||||||
|
) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createATA = async (): Promise<TypeHelper> => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-expect-error
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
const ts = await import('https://esm.sh/typescript@5.3.3')
|
const ts = await import('https://esm.sh/typescript@5.3.3')
|
||||||
const ata = setupTypeAcquisition({
|
const ata = setupTypeAcquisition({
|
||||||
projectName: 'monaco-ts',
|
projectName: 'monaco-ts',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
typescript: ts,
|
typescript: ts,
|
||||||
logger: console,
|
logger: console,
|
||||||
fetcher: (input, init) => {
|
fetcher: (input, init) => {
|
||||||
let result: any
|
|
||||||
try {
|
try {
|
||||||
result = fetch(input, init)
|
return fetch(input, init)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching data:', error)
|
console.error('Error fetching data:', error)
|
||||||
}
|
}
|
||||||
return result
|
return new Promise(() => {})
|
||||||
},
|
},
|
||||||
delegate: {
|
delegate: {
|
||||||
receivedFile: (code, path) => {
|
receivedFile: (code, path) => {
|
||||||
delegateListener.receivedFile.forEach((fn) => fn(code, path))
|
delegateListener.receivedFile.forEach((fn) => fn(code, path))
|
||||||
},
|
},
|
||||||
progress: (downloaded, estimatedTotal) => {
|
|
||||||
delegateListener.progress.forEach((fn) => fn(downloaded, estimatedTotal))
|
|
||||||
},
|
|
||||||
started: () => {
|
started: () => {
|
||||||
delegateListener.started.forEach((fn) => fn())
|
delegateListener.started.forEach((fn) => fn())
|
||||||
},
|
},
|
||||||
|
progress: (downloaded, estimatedTotal) => {
|
||||||
|
delegateListener.progress.forEach((fn) => fn(downloaded, estimatedTotal))
|
||||||
|
},
|
||||||
finished: (files) => {
|
finished: (files) => {
|
||||||
delegateListener.finished.forEach((fn) => fn(files))
|
delegateListener.finished.forEach((fn) => fn(files))
|
||||||
}
|
}
|
||||||
@@ -56,7 +71,8 @@ export const createATA = async () => {
|
|||||||
event: T,
|
event: T,
|
||||||
handler: InferSet<DelegateListener[T]>
|
handler: InferSet<DelegateListener[T]>
|
||||||
) => {
|
) => {
|
||||||
// @ts-ignore
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-expect-error
|
||||||
delegateListener[event].add(handler)
|
delegateListener[event].add(handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +80,8 @@ export const createATA = async () => {
|
|||||||
event: T,
|
event: T,
|
||||||
handler: InferSet<DelegateListener[T]>
|
handler: InferSet<DelegateListener[T]>
|
||||||
) => {
|
) => {
|
||||||
// @ts-ignore
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-expect-error
|
||||||
delegateListener[event].delete(handler)
|
delegateListener[event].delete(handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { editor, IPosition, Selection } from 'monaco-editor'
|
|||||||
import ScrollType = editor.ScrollType
|
import ScrollType = editor.ScrollType
|
||||||
import { Monaco } from '@monaco-editor/react'
|
import { Monaco } from '@monaco-editor/react'
|
||||||
import { getWorker, MonacoJsxSyntaxHighlight } from 'monaco-jsx-syntax-highlight'
|
import { getWorker, MonacoJsxSyntaxHighlight } from 'monaco-jsx-syntax-highlight'
|
||||||
import { createATA } from '@/components/ReactPlayground/CodeEditor/Editor/ata.ts'
|
import { createATA, TypeHelper } from '@/components/ReactPlayground/CodeEditor/Editor/ata.ts'
|
||||||
|
|
||||||
export const useEditor = () => {
|
export const useEditor = () => {
|
||||||
const doOpenEditor = (
|
const doOpenEditor = (
|
||||||
@@ -44,7 +44,7 @@ export const useEditor = () => {
|
|||||||
editor: editor.IStandaloneCodeEditor,
|
editor: editor.IStandaloneCodeEditor,
|
||||||
monaco: Monaco,
|
monaco: Monaco,
|
||||||
defaultValue: string,
|
defaultValue: string,
|
||||||
onWatch: any
|
onWatch: (typeHelper: TypeHelper) => () => void
|
||||||
) => {
|
) => {
|
||||||
const typeHelper = await createATA()
|
const typeHelper = await createATA()
|
||||||
|
|
||||||
@@ -70,3 +70,40 @@ export const useEditor = () => {
|
|||||||
autoLoadExtraLib
|
autoLoadExtraLib
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useTypesProgress = () => {
|
||||||
|
const [progress, setProgress] = useState(0)
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [finished, setFinished] = useState(false)
|
||||||
|
|
||||||
|
const onWatch = (typeHelper: TypeHelper) => {
|
||||||
|
const handleStarted = () => {
|
||||||
|
setFinished(false)
|
||||||
|
}
|
||||||
|
typeHelper.addListener('started', handleStarted)
|
||||||
|
|
||||||
|
const handleProgress = (progress: number, total: number) => {
|
||||||
|
setProgress(progress)
|
||||||
|
setTotal(total)
|
||||||
|
}
|
||||||
|
typeHelper.addListener('progress', handleProgress)
|
||||||
|
|
||||||
|
const handleFinished = () => {
|
||||||
|
setFinished(true)
|
||||||
|
}
|
||||||
|
typeHelper.addListener('progress', handleFinished)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
typeHelper.removeListener('started', handleStarted)
|
||||||
|
typeHelper.removeListener('progress', handleProgress)
|
||||||
|
typeHelper.removeListener('finished', handleFinished)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
progress,
|
||||||
|
total,
|
||||||
|
finished,
|
||||||
|
onWatch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,23 +2,30 @@ import React from 'react'
|
|||||||
import { editor, Selection } from 'monaco-editor'
|
import { editor, Selection } from 'monaco-editor'
|
||||||
import MonacoEditor, { Monaco } from '@monaco-editor/react'
|
import MonacoEditor, { Monaco } from '@monaco-editor/react'
|
||||||
import '@/components/ReactPlayground/CodeEditor/Editor/editor.scss'
|
import '@/components/ReactPlayground/CodeEditor/Editor/editor.scss'
|
||||||
import { IEditorOptions, IFile, IFiles, ITheme } from '@/components/ReactPlayground/shared'
|
import { IEditorOptions, IFiles, ITheme } from '@/components/ReactPlayground/shared'
|
||||||
import { MonacoEditorConfig } from '@/components/ReactPlayground/CodeEditor/Editor/monacoConfig'
|
import { MonacoEditorConfig } from '@/components/ReactPlayground/CodeEditor/Editor/monacoConfig'
|
||||||
import { fileNameToLanguage } from '@/components/ReactPlayground/utils'
|
import { fileNameToLanguage } from '@/components/ReactPlayground/utils'
|
||||||
import { useEditor } from '@/components/ReactPlayground/CodeEditor/Editor/hooks'
|
import { useEditor, useTypesProgress } from '@/components/ReactPlayground/CodeEditor/Editor/hooks'
|
||||||
|
|
||||||
interface EditorProps {
|
interface EditorProps {
|
||||||
file: IFile
|
files?: IFiles
|
||||||
|
selectedFileName?: string
|
||||||
onChange?: (code: string | undefined) => void
|
onChange?: (code: string | undefined) => void
|
||||||
options?: IEditorOptions
|
options?: IEditorOptions
|
||||||
theme?: ITheme
|
theme?: ITheme
|
||||||
files?: IFiles
|
|
||||||
onJumpFile?: (fileName: string) => void
|
onJumpFile?: (fileName: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const Editor: React.FC<EditorProps> = ({ file, files, theme, onChange, options, onJumpFile }) => {
|
const Editor: React.FC<EditorProps> = ({
|
||||||
|
files = {},
|
||||||
|
selectedFileName = '',
|
||||||
|
theme,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
onJumpFile
|
||||||
|
}) => {
|
||||||
const editorRef = useRef<editor.IStandaloneCodeEditor>()
|
const editorRef = useRef<editor.IStandaloneCodeEditor>()
|
||||||
const { doOpenEditor, loadJsxSyntaxHighlight } = useEditor()
|
const { doOpenEditor, loadJsxSyntaxHighlight, autoLoadExtraLib } = useEditor()
|
||||||
const jsxSyntaxHighlightRef = useRef<{
|
const jsxSyntaxHighlightRef = useRef<{
|
||||||
highlighter: (code?: string | undefined) => void
|
highlighter: (code?: string | undefined) => void
|
||||||
dispose: () => void
|
dispose: () => void
|
||||||
@@ -26,6 +33,8 @@ const Editor: React.FC<EditorProps> = ({ file, files, theme, onChange, options,
|
|||||||
highlighter: () => undefined,
|
highlighter: () => undefined,
|
||||||
dispose: () => undefined
|
dispose: () => undefined
|
||||||
})
|
})
|
||||||
|
const { onWatch } = useTypesProgress()
|
||||||
|
const file = files[selectedFileName] ?? { name: 'Untitled' }
|
||||||
|
|
||||||
const handleOnEditorDidMount = (editor: editor.IStandaloneCodeEditor, monaco: Monaco) => {
|
const handleOnEditorDidMount = (editor: editor.IStandaloneCodeEditor, monaco: Monaco) => {
|
||||||
editorRef.current = editor
|
editorRef.current = editor
|
||||||
@@ -64,6 +73,8 @@ const Editor: React.FC<EditorProps> = ({ file, files, theme, onChange, options,
|
|||||||
}
|
}
|
||||||
|
|
||||||
jsxSyntaxHighlightRef.current = loadJsxSyntaxHighlight(editor, monaco)
|
jsxSyntaxHighlightRef.current = loadJsxSyntaxHighlight(editor, monaco)
|
||||||
|
|
||||||
|
void autoLoadExtraLib(editor, monaco, file.value, onWatch)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -12,23 +12,23 @@ export const MonacoEditorConfig: editor.IStandaloneEditorConstructionOptions = {
|
|||||||
showFoldingControls: 'mouseover',
|
showFoldingControls: 'mouseover',
|
||||||
scrollBeyondLastLine: false,
|
scrollBeyondLastLine: false,
|
||||||
minimap: {
|
minimap: {
|
||||||
enabled: false,
|
enabled: false
|
||||||
},
|
},
|
||||||
inlineSuggest: {
|
inlineSuggest: {
|
||||||
enabled: false,
|
enabled: false
|
||||||
},
|
},
|
||||||
fixedOverflowWidgets: true,
|
fixedOverflowWidgets: true,
|
||||||
smoothScrolling: true,
|
smoothScrolling: true,
|
||||||
smartSelect: {
|
smartSelect: {
|
||||||
selectSubwords: true,
|
selectSubwords: true,
|
||||||
selectLeadingAndTrailingWhitespace: true,
|
selectLeadingAndTrailingWhitespace: true
|
||||||
},
|
},
|
||||||
tabSize: 2,
|
tabSize: 2,
|
||||||
overviewRulerBorder: false, // 不要滚动条的边框
|
overviewRulerBorder: false, // 不要滚动条的边框
|
||||||
// 滚动条设置
|
// 滚动条设置
|
||||||
scrollbar: {
|
scrollbar: {
|
||||||
verticalScrollbarSize: 6, // 竖滚动条
|
verticalScrollbarSize: 6, // 竖滚动条
|
||||||
horizontalScrollbarSize: 6, // 横滚动条
|
horizontalScrollbarSize: 6 // 横滚动条
|
||||||
},
|
}
|
||||||
// lineNumbers: 'off', // 隐藏控制行号
|
// lineNumbers: 'off', // 隐藏控制行号
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
72
src/components/ReactPlayground/CodeEditor/index.tsx
Normal file
72
src/components/ReactPlayground/CodeEditor/index.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import FileSelector from '@/components/ReactPlayground/CodeEditor/FileSelector'
|
||||||
|
import Editor from '@/components/ReactPlayground/CodeEditor/Editor'
|
||||||
|
import { IEditorOptions, IFiles, ITheme } from '@/components/ReactPlayground/shared.ts'
|
||||||
|
import FitFullscreen from '@/components/common/FitFullscreen.tsx'
|
||||||
|
import FlexBox from '@/components/common/FlexBox.tsx'
|
||||||
|
import _ from 'lodash'
|
||||||
|
|
||||||
|
interface CodeEditorProps {
|
||||||
|
theme: ITheme
|
||||||
|
files: IFiles
|
||||||
|
readonly?: boolean
|
||||||
|
readonlyFiles?: string[]
|
||||||
|
selectedFileName: string
|
||||||
|
options?: IEditorOptions
|
||||||
|
onSelectedFileChange?: (fileName: string) => void
|
||||||
|
onRemoveFile?: (fileName: string, files: IFiles) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CodeEditor: React.FC<CodeEditorProps> = ({
|
||||||
|
theme,
|
||||||
|
files,
|
||||||
|
readonly,
|
||||||
|
readonlyFiles,
|
||||||
|
options,
|
||||||
|
onSelectedFileChange,
|
||||||
|
onRemoveFile,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [selectedFileName, setSelectedFileName] = useState(props.selectedFileName)
|
||||||
|
|
||||||
|
const handleOnChangeSelectedFile = (fileName: string) => {
|
||||||
|
if (onSelectedFileChange) {
|
||||||
|
onSelectedFileChange(fileName)
|
||||||
|
} else {
|
||||||
|
setSelectedFileName(fileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOnRemoveFile = (fileName: string) => {
|
||||||
|
const clone = _.cloneDeep(files)
|
||||||
|
delete clone[fileName]
|
||||||
|
onRemoveFile?.(fileName, clone)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FitFullscreen>
|
||||||
|
<FlexBox style={{ height: '100%' }}>
|
||||||
|
<FileSelector
|
||||||
|
files={files}
|
||||||
|
readonly={readonly}
|
||||||
|
readonlyFiles={readonlyFiles}
|
||||||
|
selectedFileName={
|
||||||
|
onSelectedFileChange ? props.selectedFileName : selectedFileName
|
||||||
|
}
|
||||||
|
onChange={handleOnChangeSelectedFile}
|
||||||
|
onRemoveFile={handleOnRemoveFile}
|
||||||
|
/>
|
||||||
|
<Editor
|
||||||
|
theme={theme}
|
||||||
|
selectedFileName={selectedFileName}
|
||||||
|
files={files}
|
||||||
|
options={options}
|
||||||
|
/>
|
||||||
|
</FlexBox>
|
||||||
|
</FitFullscreen>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CodeEditor
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { editor } from 'monaco-editor'
|
import { editor } from 'monaco-editor'
|
||||||
|
|
||||||
|
export type ILanguage = 'javascript' | 'typescript' | 'json' | 'css'
|
||||||
|
|
||||||
export interface IFile {
|
export interface IFile {
|
||||||
name: string
|
name: string
|
||||||
value: string
|
value: string
|
||||||
language: 'javascript' | 'typescript' | 'json' | 'css'
|
language: ILanguage
|
||||||
active?: boolean
|
active?: boolean
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { strFromU8, strToU8, unzlibSync, zlibSync } from 'fflate'
|
import { strFromU8, strToU8, unzlibSync, zlibSync } from 'fflate'
|
||||||
import { ICustomFiles, IFiles, IImportMap, ITheme } from '@/components/ReactPlayground/shared'
|
import {
|
||||||
|
ICustomFiles,
|
||||||
|
IFiles,
|
||||||
|
IImportMap,
|
||||||
|
ILanguage,
|
||||||
|
ITheme
|
||||||
|
} from '@/components/ReactPlayground/shared'
|
||||||
import { IMPORT_MAP_FILE_NAME, reactTemplateFiles } from '@/components/ReactPlayground/files'
|
import { IMPORT_MAP_FILE_NAME, reactTemplateFiles } from '@/components/ReactPlayground/files'
|
||||||
|
|
||||||
export const strToBase64 = (str: string) => {
|
export const strToBase64 = (str: string) => {
|
||||||
@@ -31,8 +37,8 @@ export const setPlaygroundTheme = (theme: ITheme) => {
|
|||||||
?.forEach((item) => item.setAttribute('class', theme))
|
?.forEach((item) => item.setAttribute('class', theme))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getPlaygroundTheme = () => {
|
export const getPlaygroundTheme = (): ITheme => {
|
||||||
const isDarkTheme = JSON.parse(localStorage.getItem(STORAGE_DARK_THEME) || 'false')
|
const isDarkTheme = JSON.parse(localStorage.getItem(STORAGE_DARK_THEME) || 'false') as ITheme
|
||||||
return isDarkTheme ? 'vs-dark' : 'light'
|
return isDarkTheme ? 'vs-dark' : 'light'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +102,7 @@ export const getFilesFromUrl = () => {
|
|||||||
try {
|
try {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const hash = window.location.hash
|
const hash = window.location.hash
|
||||||
if (hash) files = JSON.parse(base64ToStr(hash?.split('#')[1]))
|
if (hash) files = JSON.parse(base64ToStr(hash?.split('#')[1])) as IFiles
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
@@ -104,7 +110,7 @@ export const getFilesFromUrl = () => {
|
|||||||
return files
|
return files
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fileNameToLanguage = (name: string) => {
|
export const fileNameToLanguage = (name: string): ILanguage => {
|
||||||
const suffix = name.split('.').pop() || ''
|
const suffix = name.split('.').pop() || ''
|
||||||
if (['js', 'jsx'].includes(suffix)) return 'javascript'
|
if (['js', 'jsx'].includes(suffix)) return 'javascript'
|
||||||
if (['ts', 'tsx'].includes(suffix)) return 'typescript'
|
if (['ts', 'tsx'].includes(suffix)) return 'typescript'
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
import React from 'react'
|
import React, { useState } from 'react'
|
||||||
import Editor from '@/components/ReactPlayground/CodeEditor/Editor'
|
import CodeEditor from '@/components/ReactPlayground/CodeEditor'
|
||||||
|
import { fileNameToLanguage } from '@/components/ReactPlayground/utils.ts'
|
||||||
|
import { IFiles } from '@/components/ReactPlayground/shared.ts'
|
||||||
|
|
||||||
const OnlineEditor: React.FC = () => {
|
const OnlineEditor: React.FC = () => {
|
||||||
|
const [files, setFiles] = useState<IFiles>({
|
||||||
|
abc: {
|
||||||
|
name: 'App.tsx',
|
||||||
|
language: fileNameToLanguage('App.tsx'),
|
||||||
|
value: 'const a = () => {}'
|
||||||
|
},
|
||||||
|
cde: {
|
||||||
|
name: 'App.css',
|
||||||
|
language: fileNameToLanguage('App.css'),
|
||||||
|
value: '.title {}'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Editor
|
<CodeEditor
|
||||||
theme={'light'}
|
theme={'light'}
|
||||||
file={{
|
files={files}
|
||||||
name: 'App.tsx',
|
selectedFileName={'abc'}
|
||||||
language: 'typescript',
|
onRemoveFile={(_, files) => setFiles(files)}
|
||||||
value: 'const a = () => {}'
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user