Complete main UI #37
@@ -1,7 +1,11 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import '@/components/Playground/CodeEditor/FileSelector/file-selector.scss'
|
import '@/components/Playground/CodeEditor/FileSelector/file-selector.scss'
|
||||||
import { IFiles } from '@/components/Playground/shared'
|
import { IFiles } from '@/components/Playground/shared'
|
||||||
import { ENTRY_FILE_NAME, IMPORT_MAP_FILE_NAME } from '@/components/Playground/files'
|
import {
|
||||||
|
ENTRY_FILE_NAME,
|
||||||
|
getFileNameList,
|
||||||
|
IMPORT_MAP_FILE_NAME
|
||||||
|
} from '@/components/Playground/files'
|
||||||
import Item from '@/components/Playground/CodeEditor/FileSelector/Item'
|
import Item from '@/components/Playground/CodeEditor/FileSelector/Item'
|
||||||
import HideScrollbar, { HideScrollbarElement } from '@/components/common/HideScrollbar'
|
import HideScrollbar, { HideScrollbarElement } from '@/components/common/HideScrollbar'
|
||||||
import FlexBox from '@/components/common/FlexBox'
|
import FlexBox from '@/components/common/FlexBox'
|
||||||
@@ -120,7 +124,7 @@ const FileSelector: React.FC<FileSelectorProps> = ({
|
|||||||
const handleOnRemove = (fileName: string) => {
|
const handleOnRemove = (fileName: string) => {
|
||||||
onRemoveFile?.(fileName)
|
onRemoveFile?.(fileName)
|
||||||
if (fileName === selectedFileName) {
|
if (fileName === selectedFileName) {
|
||||||
const keys = Object.keys(files).filter(
|
const keys = getFileNameList(files).filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
![IMPORT_MAP_FILE_NAME, ENTRY_FILE_NAME].includes(item) && !files[item].hidden
|
![IMPORT_MAP_FILE_NAME, ENTRY_FILE_NAME].includes(item) && !files[item].hidden
|
||||||
)
|
)
|
||||||
@@ -134,9 +138,9 @@ const FileSelector: React.FC<FileSelectorProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Object.keys(files).length
|
getFileNameList(files).length
|
||||||
? setTabs(
|
? setTabs(
|
||||||
Object.keys(files).filter(
|
getFileNameList(files).filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
![IMPORT_MAP_FILE_NAME, ENTRY_FILE_NAME].includes(item) &&
|
![IMPORT_MAP_FILE_NAME, ENTRY_FILE_NAME].includes(item) &&
|
||||||
!files[item].hidden
|
!files[item].hidden
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { IEditorOptions, IFiles, ITheme } from '@/components/Playground/shared'
|
|||||||
import {
|
import {
|
||||||
ENTRY_FILE_NAME,
|
ENTRY_FILE_NAME,
|
||||||
fileNameToLanguage,
|
fileNameToLanguage,
|
||||||
|
getFileNameList,
|
||||||
IMPORT_MAP_FILE_NAME
|
IMPORT_MAP_FILE_NAME
|
||||||
} from '@/components/Playground/files'
|
} from '@/components/Playground/files'
|
||||||
import FileSelector from '@/components/Playground/CodeEditor/FileSelector'
|
import FileSelector from '@/components/Playground/CodeEditor/FileSelector'
|
||||||
@@ -42,7 +43,7 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
|
|||||||
onError,
|
onError,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const filteredFilesName = Object.keys(files).filter(
|
const filteredFilesName = getFileNameList(files).filter(
|
||||||
(item) => ![IMPORT_MAP_FILE_NAME, ENTRY_FILE_NAME].includes(item) && !files[item].hidden
|
(item) => ![IMPORT_MAP_FILE_NAME, ENTRY_FILE_NAME].includes(item) && !files[item].hidden
|
||||||
)
|
)
|
||||||
const propsSelectedFileName =
|
const propsSelectedFileName =
|
||||||
|
|||||||
@@ -2,18 +2,19 @@ import React from 'react'
|
|||||||
import MonacoEditor from '@monaco-editor/react'
|
import MonacoEditor from '@monaco-editor/react'
|
||||||
import { Loader } from 'esbuild-wasm'
|
import { Loader } from 'esbuild-wasm'
|
||||||
import { useUpdatedEffect } from '@/util/hooks'
|
import { useUpdatedEffect } from '@/util/hooks'
|
||||||
import { IFile, ITheme } from '@/components/Playground/shared'
|
import { IFiles, IImportMap, ITheme } from '@/components/Playground/shared'
|
||||||
import Compiler from '@/components/Playground/compiler'
|
import Compiler from '@/components/Playground/compiler'
|
||||||
import { cssToJs, jsonToJs } from '@/components/Playground/files'
|
import { cssToJs, jsonToJs } from '@/components/Playground/files'
|
||||||
import { MonacoEditorConfig } from '@/components/Playground/CodeEditor/Editor/monacoConfig'
|
import { MonacoEditorConfig } from '@/components/Playground/CodeEditor/Editor/monacoConfig'
|
||||||
import { addReactImport } from '@/components/Playground/utils.ts'
|
import { addReactImport } from '@/components/Playground/utils.ts'
|
||||||
|
|
||||||
interface OutputProps {
|
interface OutputProps {
|
||||||
file: IFile
|
files: IFiles
|
||||||
|
selectedFileName: string
|
||||||
theme?: ITheme
|
theme?: ITheme
|
||||||
}
|
}
|
||||||
|
|
||||||
const Preview: React.FC<OutputProps> = ({ file, theme }) => {
|
const Preview: React.FC<OutputProps> = ({ files, selectedFileName, theme }) => {
|
||||||
const compiler = useRef<Compiler>()
|
const compiler = useRef<Compiler>()
|
||||||
const [compileCode, setCompileCode] = useState('')
|
const [compileCode, setCompileCode] = useState('')
|
||||||
|
|
||||||
@@ -37,14 +38,25 @@ const Preview: React.FC<OutputProps> = ({ file, theme }) => {
|
|||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error('编译失败', e)
|
console.error('编译失败', e)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
compiler.current
|
||||||
|
?.compile(files, {
|
||||||
|
imports: {
|
||||||
|
react: 'https://esm.sh/react@18.2.0',
|
||||||
|
'react-dom/client': 'https://esm.sh/react-dom@18.2.0'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((r) => {
|
||||||
|
console.log(r)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
useUpdatedEffect(() => {
|
useUpdatedEffect(() => {
|
||||||
if (file) {
|
if (files[selectedFileName]) {
|
||||||
try {
|
try {
|
||||||
const code = file.value
|
const code = files[selectedFileName].value
|
||||||
|
|
||||||
switch (file.language) {
|
switch (files[selectedFileName].language) {
|
||||||
case 'typescript':
|
case 'typescript':
|
||||||
compile(code, 'tsx')
|
compile(code, 'tsx')
|
||||||
break
|
break
|
||||||
@@ -52,10 +64,10 @@ const Preview: React.FC<OutputProps> = ({ file, theme }) => {
|
|||||||
compile(code, 'jsx')
|
compile(code, 'jsx')
|
||||||
break
|
break
|
||||||
case 'css':
|
case 'css':
|
||||||
setCompileCode(cssToJs(file))
|
setCompileCode(cssToJs(files[selectedFileName]))
|
||||||
break
|
break
|
||||||
case 'json':
|
case 'json':
|
||||||
setCompileCode(jsonToJs(file))
|
setCompileCode(jsonToJs(files[selectedFileName]))
|
||||||
break
|
break
|
||||||
case 'xml':
|
case 'xml':
|
||||||
setCompileCode(code)
|
setCompileCode(code)
|
||||||
@@ -66,7 +78,7 @@ const Preview: React.FC<OutputProps> = ({ file, theme }) => {
|
|||||||
} else {
|
} else {
|
||||||
setCompileCode('')
|
setCompileCode('')
|
||||||
}
|
}
|
||||||
}, [file])
|
}, [files[selectedFileName]])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import esbuild, { Loader } from 'esbuild-wasm'
|
import esbuild, { Loader, OnLoadArgs, Plugin, PluginBuild } from 'esbuild-wasm'
|
||||||
import wasm from 'esbuild-wasm/esbuild.wasm?url'
|
import wasm from 'esbuild-wasm/esbuild.wasm?url'
|
||||||
|
import { IFiles, IImportMap } from '@/components/Playground/shared.ts'
|
||||||
|
import { cssToJs, ENTRY_FILE_NAME, jsonToJs } from '@/components/Playground/files.ts'
|
||||||
|
import localforage from 'localforage'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
class Compiler {
|
class Compiler {
|
||||||
private init = false
|
private init = false
|
||||||
|
|
||||||
|
fileCache = localforage.createInstance({
|
||||||
|
name: 'fileCache'
|
||||||
|
})
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
try {
|
try {
|
||||||
void esbuild.initialize({ worker: true, wasmURL: wasm }).then(() => {
|
void esbuild.initialize({ worker: true, wasmURL: wasm }).then(() => {
|
||||||
@@ -15,20 +23,180 @@ class Compiler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
transform = (code: string, loader: Loader) =>
|
transform = (code: string, loader: Loader) =>
|
||||||
new Promise<boolean>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
if (this.init) {
|
if (this.init) {
|
||||||
resolve(true)
|
resolve()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
if (this.init) {
|
if (this.init) {
|
||||||
clearInterval(timer)
|
clearInterval(timer)
|
||||||
resolve(true)
|
resolve()
|
||||||
}
|
}
|
||||||
}, 100)
|
}, 100)
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
return esbuild.transform(code, { loader })
|
return esbuild.transform(code, { loader })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
compile = (files: IFiles, importMap: IImportMap) =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
if (this.init) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
if (this.init) {
|
||||||
|
clearInterval(timer)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
}).then(() => {
|
||||||
|
return esbuild.build({
|
||||||
|
bundle: true,
|
||||||
|
entryPoints: [ENTRY_FILE_NAME],
|
||||||
|
format: 'esm',
|
||||||
|
metafile: true,
|
||||||
|
write: false,
|
||||||
|
plugins: [this.fileResolverPlugin(files, importMap)]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
stop = () => {
|
||||||
|
esbuild.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fileResolverPlugin = (files: IFiles, importMap: IImportMap): Plugin => {
|
||||||
|
return {
|
||||||
|
name: 'file-resolver-plugin',
|
||||||
|
setup: (build: PluginBuild) => {
|
||||||
|
build.onResolve({ filter: /.*/ }, async (args: esbuild.OnResolveArgs) => {
|
||||||
|
if (args.path === ENTRY_FILE_NAME) {
|
||||||
|
return {
|
||||||
|
namespace: 'OxygenToolbox',
|
||||||
|
path: args.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (args.path.startsWith('./') && files[args.path.substring(2)]) {
|
||||||
|
return {
|
||||||
|
namespace: 'OxygenToolbox',
|
||||||
|
path: args.path.substring(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (args.path.startsWith('./') && files[`${args.path.substring(2)}.tsx`]) {
|
||||||
|
return {
|
||||||
|
namespace: 'OxygenToolbox',
|
||||||
|
path: `${args.path.substring(2)}.tsx`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (args.path.startsWith('./') && files[`${args.path.substring(2)}.jsx`]) {
|
||||||
|
return {
|
||||||
|
namespace: 'OxygenToolbox',
|
||||||
|
path: `${args.path.substring(2)}.jsx`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (args.path.startsWith('./') && files[`${args.path.substring(2)}.ts`]) {
|
||||||
|
return {
|
||||||
|
namespace: 'OxygenToolbox',
|
||||||
|
path: `${args.path.substring(2)}.ts`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (args.path.startsWith('./') && files[`${args.path.substring(2)}.js`]) {
|
||||||
|
return {
|
||||||
|
namespace: 'OxygenToolbox',
|
||||||
|
path: `${args.path.substring(2)}.js`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = importMap.imports[args.path]
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
throw Error(`Import '${args.path}' not found in Import Map`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
namespace: 'default',
|
||||||
|
path
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
build.onLoad({ filter: /.*\.css$/ }, async (args: OnLoadArgs) => {
|
||||||
|
const contents = cssToJs(files[args.path])
|
||||||
|
return {
|
||||||
|
loader: 'js',
|
||||||
|
contents
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
build.onLoad({ filter: /.*\.json$/ }, async (args: OnLoadArgs) => {
|
||||||
|
const contents = jsonToJs(files[args.path])
|
||||||
|
return {
|
||||||
|
loader: 'js',
|
||||||
|
contents
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
build.onLoad({ filter: /.*\.svg$/ }, async (args: OnLoadArgs) => {
|
||||||
|
const contents = files[args.path].value
|
||||||
|
return {
|
||||||
|
loader: 'text',
|
||||||
|
contents
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
build.onLoad({ filter: /.*/ }, async (args: OnLoadArgs) => {
|
||||||
|
if (args.path === ENTRY_FILE_NAME) {
|
||||||
|
return {
|
||||||
|
loader: 'tsx',
|
||||||
|
contents: files[ENTRY_FILE_NAME].value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files[args.path]) {
|
||||||
|
const contents = files[args.path].value
|
||||||
|
if (args.path.endsWith('.jsx')) {
|
||||||
|
return {
|
||||||
|
loader: 'jsx',
|
||||||
|
contents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (args.path.endsWith('.ts')) {
|
||||||
|
return {
|
||||||
|
loader: 'ts',
|
||||||
|
contents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (args.path.endsWith('.js')) {
|
||||||
|
return {
|
||||||
|
loader: 'js',
|
||||||
|
contents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
loader: 'tsx',
|
||||||
|
contents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = await this.fileCache.getItem<esbuild.OnLoadResult>(args.path)
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, request } = await axios.get(args.path)
|
||||||
|
const result: esbuild.OnLoadResult = {
|
||||||
|
loader: 'jsx',
|
||||||
|
contents: data,
|
||||||
|
resolveDir: new URL('./', request.responseURL).pathname
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.fileCache.setItem(args.path, request)
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Compiler
|
export default Compiler
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export const MAIN_FILE_NAME = 'App.tsx'
|
|||||||
export const IMPORT_MAP_FILE_NAME = 'import-map.json'
|
export const IMPORT_MAP_FILE_NAME = 'import-map.json'
|
||||||
export const ENTRY_FILE_NAME = 'main.tsx'
|
export const ENTRY_FILE_NAME = 'main.tsx'
|
||||||
|
|
||||||
|
export const getFileNameList = (files: IFiles) => Object.keys(files)
|
||||||
|
|
||||||
export const fileNameToLanguage = (name: string): ILanguage => {
|
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'
|
||||||
@@ -55,7 +57,7 @@ export const getFilesFromUrl = () => {
|
|||||||
export const getModuleFile = (files: IFiles, moduleName: string) => {
|
export const getModuleFile = (files: IFiles, moduleName: string) => {
|
||||||
let _moduleName = moduleName.split('./').pop() || ''
|
let _moduleName = moduleName.split('./').pop() || ''
|
||||||
if (!_moduleName.includes('.')) {
|
if (!_moduleName.includes('.')) {
|
||||||
const realModuleName = Object.keys(files).find((key) =>
|
const realModuleName = getFileNameList(files).find((key) =>
|
||||||
key.split('.').includes(_moduleName)
|
key.split('.').includes(_moduleName)
|
||||||
)
|
)
|
||||||
if (realModuleName) _moduleName = realModuleName
|
if (realModuleName) _moduleName = realModuleName
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ export interface IFiles {
|
|||||||
[key: string]: IFile
|
[key: string]: IFile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IImportMap {
|
||||||
|
imports: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
export type ITheme = 'light' | 'vs-dark'
|
export type ITheme = 'light' | 'vs-dark'
|
||||||
|
|
||||||
export type IEditorOptions = editor.IStandaloneEditorConstructionOptions
|
export type IEditorOptions = editor.IStandaloneEditorConstructionOptions
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const OnlineEditor: React.FC = () => {
|
|||||||
onRenameFile={(_, __, files) => setFiles(files)}
|
onRenameFile={(_, __, files) => setFiles(files)}
|
||||||
onChangeFileContent={(_, __, files) => setFiles(files)}
|
onChangeFileContent={(_, __, files) => setFiles(files)}
|
||||||
/>
|
/>
|
||||||
<Transform file={files[selectedFileName]} />
|
<Transform files={files} selectedFileName={selectedFileName} />
|
||||||
</FlexBox>
|
</FlexBox>
|
||||||
</FitFullscreen>
|
</FitFullscreen>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user