Finish CodeEditor

This commit is contained in:
2024-01-09 10:47:32 +08:00
parent 5a320e459a
commit 3ec95ee8a4
12 changed files with 224 additions and 327 deletions

View File

@@ -3,7 +3,7 @@ import { editor, Selection } from 'monaco-editor'
import MonacoEditor, { Monaco } from '@monaco-editor/react'
import '@/components/ReactPlayground/CodeEditor/Editor/editor.scss'
import { IEditorOptions, IFiles, ITheme } from '@/components/ReactPlayground/shared'
import { fileNameToLanguage } from '@/components/ReactPlayground/utils'
import { fileNameToLanguage } from '@/components/ReactPlayground/files'
import { useEditor, useTypesProgress } from '@/components/ReactPlayground/CodeEditor/Editor/hooks'
import { MonacoEditorConfig } from '@/components/ReactPlayground/CodeEditor/Editor/monacoConfig'

View File

@@ -1,6 +1,7 @@
import React from 'react'
interface ItemProps {
className?: string
readonly?: boolean
creating?: boolean
value: string
@@ -15,6 +16,7 @@ interface ItemProps {
}
const Item: React.FC<ItemProps> = ({
className,
readonly = false,
value,
active = false,
@@ -105,7 +107,10 @@ const Item: React.FC<ItemProps> = ({
}, [])
return (
<div className={`tab-item${active ? ' active' : ''}`} onClick={handleOnClick}>
<div
className={`tab-item${active ? ' active' : ''}${className ? ` ${className}` : ''}`}
onClick={handleOnClick}
>
{creating ? (
<div className={'tab-item-input'}>
<input

View File

@@ -1,13 +1,45 @@
[data-component=playground-file-selector].tab{
display: flex;
flex-direction: row;
gap: 1px;
padding: 4px 20px 0 20px;
flex: 0 0 auto;
height: 40px;
.multiple {
flex: 1;
width: 0;
.tab-content {
height: 40px;
align-items: flex-end;
gap: 2px;
margin-left: 10px;
.tab-item-add {
padding: 0 12px;
}
.tabs-margin-right {
height: 100%;
> * {
height: 100%;
width: 10px;
}
}
}
}
.sticky {
display: flex;
flex: 0 0 auto;
align-items: flex-end;
margin-right: 10px;
}
.tab-item {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
height: 30px;
padding: 0 20px;
border: 1px solid #f0f0f0;
@@ -50,4 +82,5 @@
border-bottom: none;
}
}
}

View File

@@ -3,6 +3,8 @@ import '@/components/ReactPlayground/CodeEditor/FileSelector/file-selector.scss'
import { IFiles } from '@/components/ReactPlayground/shared'
import { ENTRY_FILE_NAME, IMPORT_MAP_FILE_NAME } from '@/components/ReactPlayground/files'
import Item from '@/components/ReactPlayground/CodeEditor/FileSelector/Item'
import HideScrollbar, { HideScrollbarElement } from '@/components/common/HideScrollbar'
import FlexBox from '@/components/common/FlexBox'
interface FileSelectorProps {
files?: IFiles
@@ -27,6 +29,7 @@ const FileSelector: React.FC<FileSelectorProps> = ({
onUpdateFileName,
selectedFileName = ''
}) => {
const hideScrollbarRef = useRef<HideScrollbarElement>(null)
const [tabs, setTabs] = useState<string[]>([])
const [creating, setCreating] = useState(false)
const [hasEditing, setHasEditing] = useState(false)
@@ -47,6 +50,9 @@ const FileSelector: React.FC<FileSelectorProps> = ({
const addTab = () => {
setTabs([...tabs, getMaxSequenceTabName(tabs)])
setCreating(true)
setTimeout(() => {
hideScrollbarRef.current?.scrollRight(1000)
})
}
const handleOnCancel = () => {
@@ -126,16 +132,15 @@ const FileSelector: React.FC<FileSelectorProps> = ({
return (
<>
<div
data-component={'playground-file-selector'}
className={'tab'}
style={{ flex: '0 0 auto' }}
>
<div data-component={'playground-file-selector'} className={'tab'}>
<div className={'multiple'}>
<HideScrollbar ref={hideScrollbarRef}>
<FlexBox direction={'horizontal'} className={'tab-content'}>
{tabs.map((item, index) => (
<Item
key={index + item}
value={item}
active={selectedFileName == item}
active={selectedFileName === item}
creating={creating}
readonly={readonly || notRemovableFiles.includes(item)}
hasEditing={hasEditing}
@@ -147,6 +152,28 @@ const FileSelector: React.FC<FileSelectorProps> = ({
onClick={() => handleOnClickTab(item)}
/>
))}
{!readonly && (
<Item
className={'tab-item-add'}
value={'+'}
onClick={addTab}
readonly
/>
)}
<div className={'tabs-margin-right'}>
<div />
</div>
</FlexBox>
</HideScrollbar>
</div>
<div className={'sticky'}>
<Item
value={'Import Map'}
active={selectedFileName === IMPORT_MAP_FILE_NAME}
onClick={editImportMap}
readonly
/>
</div>
</div>
</>
)

View File

@@ -0,0 +1,5 @@
[data-component=playground-code-editor] {
section {
height: 0 !important;
}
}

View File

@@ -1,14 +1,15 @@
import React from 'react'
import _ from 'lodash'
import '@/components/ReactPlayground/CodeEditor/code-editor.scss'
import { IEditorOptions, IFiles, ITheme } from '@/components/ReactPlayground/shared'
import { fileNameToLanguage } from '@/components/ReactPlayground/utils'
import { fileNameToLanguage } from '@/components/ReactPlayground/files'
import FileSelector from '@/components/ReactPlayground/CodeEditor/FileSelector'
import Editor from '@/components/ReactPlayground/CodeEditor/Editor'
import FitFullscreen from '@/components/common/FitFullscreen'
import FlexBox from '@/components/common/FlexBox'
interface CodeEditorProps {
theme: ITheme
theme?: ITheme
files: IFiles
readonly?: boolean
readonlyFiles?: string[]
@@ -16,8 +17,10 @@ interface CodeEditorProps {
selectedFileName: string
options?: IEditorOptions
onSelectedFileChange?: (fileName: string) => void
onAddFile?: (fileName: string, files: IFiles) => void
onRemoveFile?: (fileName: string, files: IFiles) => void
onRenameFile?: (newFileName: string, oldFileName: string, files: IFiles) => void
onChangeFileContent?: (content: string, fileName: string, files: IFiles) => void
}
const CodeEditor: React.FC<CodeEditorProps> = ({
@@ -28,8 +31,10 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
notRemovable,
options,
onSelectedFileChange,
onAddFile,
onRemoveFile,
onRenameFile,
onChangeFileContent,
...props
}) => {
const [selectedFileName, setSelectedFileName] = useState(props.selectedFileName)
@@ -48,6 +53,17 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
onRemoveFile?.(fileName, clone)
}
const handleOnAddFile = (fileName: string) => {
const clone = _.cloneDeep(files)
clone[fileName] = {
name: fileName,
language: fileNameToLanguage(fileName),
value: ''
}
onAddFile?.(fileName, clone)
handleOnChangeSelectedFile(fileName)
}
const handleOnUpdateFileName = (newFileName: string, oldFileName: string) => {
if (!files[oldFileName] || !newFileName) {
return
@@ -66,9 +82,19 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
onRenameFile?.(newFileName, oldFileName, newFiles)
}
const handleOnChangeFileContent = (code = '') => {
const clone = _.cloneDeep(files)
clone[onSelectedFileChange ? props.selectedFileName : selectedFileName].value = code ?? ''
onChangeFileContent?.(
code,
onSelectedFileChange ? props.selectedFileName : selectedFileName,
clone
)
}
return (
<>
<FitFullscreen>
<FitFullscreen data-component={'playground-code-editor'}>
<FlexBox style={{ height: '100%' }}>
<FileSelector
files={files}
@@ -80,13 +106,22 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
onChange={handleOnChangeSelectedFile}
onRemoveFile={handleOnRemoveFile}
onUpdateFileName={handleOnUpdateFileName}
onAddFile={handleOnAddFile}
/>
<Editor
theme={theme}
selectedFileName={selectedFileName}
selectedFileName={
onSelectedFileChange ? props.selectedFileName : selectedFileName
}
files={files}
options={options}
readonly={readonly || readonlyFiles?.includes(selectedFileName)}
readonly={
readonly ||
readonlyFiles?.includes(
onSelectedFileChange ? props.selectedFileName : selectedFileName
)
}
onChange={handleOnChangeFileContent}
/>
</FlexBox>
</FitFullscreen>

View File

@@ -1,75 +0,0 @@
/*
import React from 'react'
import { IPlayground } from '@/components/ReactPlayground/shared.ts'
import { PlaygroundContext } from '@/components/ReactPlayground/Provider.tsx'
import { ENTRY_FILE_NAME, initFiles, MAIN_FILE_NAME } from '@/components/files.ts'
import {
getCustomActiveFile,
getMergedCustomFiles,
getPlaygroundTheme
} from '@/components/ReactPlayground/utils.ts'
const defaultCodeSandboxOptions = {
theme: 'dark',
editorHeight: '100vh',
showUrlHash: true
}
const Playground: React.FC<IPlayground> = (props) => {
const {
width = '100vw',
height = '100vh',
theme,
files: propsFiles,
importMap,
showCompileOutput = true,
showHeader = true,
showFileSelector = true,
fileSelectorReadOnly = false,
border = false,
defaultSizes,
onFilesChange,
autorun = true
} = props
const { filesHash, changeTheme, files, setFiles, setSelectedFileName } =
useContext(PlaygroundContext)
const options = Object.assign(defaultCodeSandboxOptions, props.options || {})
useEffect(() => {
if (propsFiles && !propsFiles?.[MAIN_FILE_NAME]) {
throw new Error(
`Missing required property : '${MAIN_FILE_NAME}' is a mandatory property for 'files'`
)
} else if (propsFiles) {
const newFiles = getMergedCustomFiles(propsFiles, importMap)
if (newFiles) {
setFiles(newFiles)
}
const selectedFileName = getCustomActiveFile(propsFiles)
if (selectedFileName) {
setSelectedFileName(selectedFileName)
}
}
}, [propsFiles])
useEffect(() => {
setTimeout(() => {
if (!theme) {
changeTheme(getPlaygroundTheme())
} else {
changeTheme(theme)
}
}, 15)
}, [theme])
useEffect(() => {
if (!propsFiles) {
setFiles(initFiles)
}
}, [])
return files[ENTRY_FILE_NAME] ? <div></div> : undefined
}
export default Playground
*/

View File

@@ -1,95 +0,0 @@
import React from 'react'
import { IFiles, IPlaygroundContext, ITheme } from '@/components/ReactPlayground/shared.ts'
import { MAIN_FILE_NAME } from '@/components/ReactPlayground/files.ts'
import {
fileNameToLanguage,
setPlaygroundTheme,
strToBase64
} from '@/components/ReactPlayground/utils.ts'
const initialContext: Partial<IPlaygroundContext> = {
selectedFileName: MAIN_FILE_NAME
}
export const PlaygroundContext = createContext<IPlaygroundContext>(
initialContext as IPlaygroundContext
)
interface ProviderProps extends React.PropsWithChildren {
saveOnUrl?: boolean
}
const Provider: React.FC<ProviderProps> = ({ children, saveOnUrl }) => {
const [files, setFiles] = useState<IFiles>({})
const [theme, setTheme] = useState(initialContext.theme!)
const [selectedFileName, setSelectedFileName] = useState(initialContext.selectedFileName!)
const [filesHash, setFilesHash] = useState('')
const addFile = (name: string) => {
files[name] = {
name,
language: fileNameToLanguage(name),
value: ''
}
setFiles({ ...files })
}
const removeFile = (name: string) => {
delete files[name]
setFiles({ ...files })
}
const changeFileName = (oldFileName: string, newFileName: string) => {
if (!files[oldFileName] || !newFileName) {
return
}
const { [oldFileName]: value, ...other } = files
const newFile: IFiles = {
[newFileName]: {
...value,
language: fileNameToLanguage(newFileName),
name: newFileName
}
}
setFiles({
...other,
...newFile
})
}
const changeTheme = (theme: ITheme) => {
setPlaygroundTheme(theme)
setTheme(theme)
}
useEffect(() => {
const hash = strToBase64(JSON.stringify(files))
if (saveOnUrl) {
window.location.hash = hash
}
setFilesHash(hash)
}, [files])
return (
<PlaygroundContext.Provider
value={{
theme,
filesHash,
files,
selectedFileName,
setTheme,
changeTheme,
setSelectedFileName,
setFiles,
addFile,
removeFile,
changeFileName
}}
>
{children}
</PlaygroundContext.Provider>
)
}
export default Provider

View File

@@ -1,15 +1,15 @@
import { strFromU8, strToU8, unzlibSync, zlibSync } from 'fflate'
import importMap from '@/components/ReactPlayground/template/import-map.json?raw'
import AppCss from '@/components/ReactPlayground/template/src/App.css?raw'
import App from '@/components/ReactPlayground/template/src/App.tsx?raw'
import main from '@/components/ReactPlayground/template/src/main.tsx?raw'
import { IFiles } from '@/components/ReactPlayground/shared'
import { base64ToStr } from '@/components/ReactPlayground/utils.ts'
import { ICustomFiles, IFiles, IImportMap } from '@/components/ReactPlayground/shared'
export const MAIN_FILE_NAME = 'App.tsx'
export const IMPORT_MAP_FILE_NAME = 'import-map.json'
export const ENTRY_FILE_NAME = 'main.tsx'
const fileNameToLanguage = (name: string) => {
export const fileNameToLanguage = (name: string) => {
const suffix = name.split('.').pop() || ''
if (['js', 'jsx'].includes(suffix)) return 'javascript'
if (['ts', 'tsx'].includes(suffix)) return 'typescript'
@@ -18,12 +18,87 @@ const fileNameToLanguage = (name: string) => {
return 'javascript'
}
const getFilesFromUrl = () => {
export const strToBase64 = (str: string) => {
const buffer = strToU8(str)
const zipped = zlibSync(buffer, { level: 9 })
const binary = strFromU8(zipped, true)
return btoa(binary)
}
export const base64ToStr = (base64: string) => {
const binary = atob(base64)
// zlib header (x78), level 9 (xDA)
if (binary.startsWith('\x78\xDA')) {
const buffer = strToU8(binary, true)
const unzipped = unzlibSync(buffer)
return strFromU8(unzipped)
}
return ''
}
const transformCustomFiles = (files: ICustomFiles) => {
const newFiles: IFiles = {}
Object.keys(files).forEach((key) => {
const tempFile = files[key]
if (typeof tempFile === 'string') {
newFiles[key] = {
name: key,
language: fileNameToLanguage(key),
value: tempFile
}
} else {
newFiles[key] = {
name: key,
language: fileNameToLanguage(key),
value: tempFile.code,
hidden: tempFile.hidden,
active: tempFile.active
}
}
})
return newFiles
}
export const getCustomActiveFile = (files?: ICustomFiles) => {
if (!files) return null
return Object.keys(files).find((key) => {
const tempFile = files[key]
if (typeof tempFile !== 'string' && tempFile.active) {
return key
}
return null
})
}
export const getMergedCustomFiles = (files?: ICustomFiles, importMap?: IImportMap) => {
if (!files) return null
if (importMap) {
return {
...reactTemplateFiles,
...transformCustomFiles(files),
[IMPORT_MAP_FILE_NAME]: {
name: IMPORT_MAP_FILE_NAME,
language: 'json',
value: JSON.stringify(importMap, null, 2)
}
}
} else {
return {
...reactTemplateFiles,
...transformCustomFiles(files)
}
}
}
export const getFilesFromUrl = () => {
let files: IFiles | undefined
try {
if (typeof window !== 'undefined') {
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) {
console.error(error)

View File

@@ -1,16 +1,8 @@
import React from 'react'
import Provider from '@/components/ReactPlayground/Provider.tsx'
import { IPlayground } from '@/components/ReactPlayground/shared.ts'
import Playground from '@/components/ReactPlayground/Playground.tsx'
import { IPlayground } from '@/components/ReactPlayground/shared'
const ReactPlayground: React.FC<IPlayground> = (props) => {
return (
<>
<Provider saveOnUrl={props.saveOnUrl}>
<Playground {...props} />
</Provider>
</>
)
const ReactPlayground: React.FC<IPlayground> = () => {
return <></>
}
export default ReactPlayground

View File

@@ -1,32 +1,4 @@
import { strFromU8, strToU8, unzlibSync, zlibSync } from 'fflate'
import {
ICustomFiles,
IFiles,
IImportMap,
ILanguage,
ITheme
} from '@/components/ReactPlayground/shared'
import { IMPORT_MAP_FILE_NAME, reactTemplateFiles } from '@/components/ReactPlayground/files'
export const strToBase64 = (str: string) => {
const buffer = strToU8(str)
const zipped = zlibSync(buffer, { level: 9 })
const binary = strFromU8(zipped, true)
return btoa(binary)
}
export const base64ToStr = (base64: string) => {
const binary = atob(base64)
// zlib header (x78), level 9 (xDA)
if (binary.startsWith('\x78\xDA')) {
const buffer = strToU8(binary, true)
const unzipped = unzlibSync(buffer)
return strFromU8(unzipped)
}
return ''
}
import { ITheme } from '@/components/ReactPlayground/shared'
const STORAGE_DARK_THEME = 'react-playground-prefer-dark'
@@ -41,81 +13,3 @@ export const getPlaygroundTheme = (): ITheme => {
const isDarkTheme = JSON.parse(localStorage.getItem(STORAGE_DARK_THEME) || 'false') as ITheme
return isDarkTheme ? 'vs-dark' : 'light'
}
const transformCustomFiles = (files: ICustomFiles) => {
const newFiles: IFiles = {}
Object.keys(files).forEach((key) => {
const tempFile = files[key]
if (typeof tempFile === 'string') {
newFiles[key] = {
name: key,
language: fileNameToLanguage(key),
value: tempFile
}
} else {
newFiles[key] = {
name: key,
language: fileNameToLanguage(key),
value: tempFile.code,
hidden: tempFile.hidden,
active: tempFile.active
}
}
})
return newFiles
}
export const getCustomActiveFile = (files?: ICustomFiles) => {
if (!files) return null
return Object.keys(files).find((key) => {
const tempFile = files[key]
if (typeof tempFile !== 'string' && tempFile.active) {
return key
}
return null
})
}
export const getMergedCustomFiles = (files?: ICustomFiles, importMap?: IImportMap) => {
if (!files) return null
if (importMap) {
return {
...reactTemplateFiles,
...transformCustomFiles(files),
[IMPORT_MAP_FILE_NAME]: {
name: IMPORT_MAP_FILE_NAME,
language: 'json',
value: JSON.stringify(importMap, null, 2)
}
}
} else {
return {
...reactTemplateFiles,
...transformCustomFiles(files)
}
}
}
export const getFilesFromUrl = () => {
let files: IFiles | undefined
try {
if (typeof window !== 'undefined') {
const hash = window.location.hash
if (hash) files = JSON.parse(base64ToStr(hash?.split('#')[1])) as IFiles
}
} catch (error) {
console.error(error)
}
return files
}
export const fileNameToLanguage = (name: string): ILanguage => {
const suffix = name.split('.').pop() || ''
if (['js', 'jsx'].includes(suffix)) return 'javascript'
if (['ts', 'tsx'].includes(suffix)) return 'typescript'
if (['json'].includes(suffix)) return 'json'
if (['css'].includes(suffix)) return 'css'
if (['svg'].includes(suffix)) return 'xml'
return 'javascript'
}

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react'
import React from 'react'
import CodeEditor from '@/components/ReactPlayground/CodeEditor'
import { fileNameToLanguage } from '@/components/ReactPlayground/utils.ts'
import { IFiles } from '@/components/ReactPlayground/shared.ts'
import { fileNameToLanguage } from '@/components/ReactPlayground/files'
import { IFiles } from '@/components/ReactPlayground/shared'
const OnlineEditor: React.FC = () => {
const [files, setFiles] = useState<IFiles>({
@@ -45,13 +45,14 @@ const OnlineEditor: React.FC = () => {
return (
<>
<CodeEditor
theme={'vs-dark'}
files={files}
selectedFileName={'App.css'}
notRemovable={['App.css']}
notRemovable={['App.css', 'fgh']}
readonlyFiles={['App.tsx']}
onAddFile={(_, files) => setFiles(files)}
onRemoveFile={(_, files) => setFiles(files)}
onRenameFile={(_, __, files) => setFiles(files)}
onChangeFileContent={(_, __, files) => setFiles(files)}
/>
</>
)