diff --git a/src/components/Playground/CodeEditor/FileSelector/index.tsx b/src/components/Playground/CodeEditor/FileSelector/index.tsx index b32547d..bdcb004 100644 --- a/src/components/Playground/CodeEditor/FileSelector/index.tsx +++ b/src/components/Playground/CodeEditor/FileSelector/index.tsx @@ -1,7 +1,11 @@ import React from 'react' import '@/components/Playground/CodeEditor/FileSelector/file-selector.scss' 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 HideScrollbar, { HideScrollbarElement } from '@/components/common/HideScrollbar' import FlexBox from '@/components/common/FlexBox' @@ -120,7 +124,7 @@ const FileSelector: React.FC = ({ const handleOnRemove = (fileName: string) => { onRemoveFile?.(fileName) if (fileName === selectedFileName) { - const keys = Object.keys(files).filter( + const keys = getFileNameList(files).filter( (item) => ![IMPORT_MAP_FILE_NAME, ENTRY_FILE_NAME].includes(item) && !files[item].hidden ) @@ -134,9 +138,9 @@ const FileSelector: React.FC = ({ } useEffect(() => { - Object.keys(files).length + getFileNameList(files).length ? setTabs( - Object.keys(files).filter( + getFileNameList(files).filter( (item) => ![IMPORT_MAP_FILE_NAME, ENTRY_FILE_NAME].includes(item) && !files[item].hidden diff --git a/src/components/Playground/CodeEditor/index.tsx b/src/components/Playground/CodeEditor/index.tsx index ea269e3..05f8b9f 100644 --- a/src/components/Playground/CodeEditor/index.tsx +++ b/src/components/Playground/CodeEditor/index.tsx @@ -5,6 +5,7 @@ import { IEditorOptions, IFiles, ITheme } from '@/components/Playground/shared' import { ENTRY_FILE_NAME, fileNameToLanguage, + getFileNameList, IMPORT_MAP_FILE_NAME } from '@/components/Playground/files' import FileSelector from '@/components/Playground/CodeEditor/FileSelector' @@ -42,7 +43,7 @@ const CodeEditor: React.FC = ({ onError, ...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 ) const propsSelectedFileName = diff --git a/src/components/Playground/Transform/index.tsx b/src/components/Playground/Transform/index.tsx index c351015..9162f14 100644 --- a/src/components/Playground/Transform/index.tsx +++ b/src/components/Playground/Transform/index.tsx @@ -2,18 +2,19 @@ import React from 'react' import MonacoEditor from '@monaco-editor/react' import { Loader } from 'esbuild-wasm' 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 { cssToJs, jsonToJs } from '@/components/Playground/files' import { MonacoEditorConfig } from '@/components/Playground/CodeEditor/Editor/monacoConfig' import { addReactImport } from '@/components/Playground/utils.ts' interface OutputProps { - file: IFile + files: IFiles + selectedFileName: string theme?: ITheme } -const Preview: React.FC = ({ file, theme }) => { +const Preview: React.FC = ({ files, selectedFileName, theme }) => { const compiler = useRef() const [compileCode, setCompileCode] = useState('') @@ -37,14 +38,25 @@ const Preview: React.FC = ({ file, theme }) => { .catch((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(() => { - if (file) { + if (files[selectedFileName]) { try { - const code = file.value + const code = files[selectedFileName].value - switch (file.language) { + switch (files[selectedFileName].language) { case 'typescript': compile(code, 'tsx') break @@ -52,10 +64,10 @@ const Preview: React.FC = ({ file, theme }) => { compile(code, 'jsx') break case 'css': - setCompileCode(cssToJs(file)) + setCompileCode(cssToJs(files[selectedFileName])) break case 'json': - setCompileCode(jsonToJs(file)) + setCompileCode(jsonToJs(files[selectedFileName])) break case 'xml': setCompileCode(code) @@ -66,7 +78,7 @@ const Preview: React.FC = ({ file, theme }) => { } else { setCompileCode('') } - }, [file]) + }, [files[selectedFileName]]) return ( <> diff --git a/src/components/Playground/compiler.ts b/src/components/Playground/compiler.ts index fd97187..854c0f5 100644 --- a/src/components/Playground/compiler.ts +++ b/src/components/Playground/compiler.ts @@ -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 { 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 { private init = false + fileCache = localforage.createInstance({ + name: 'fileCache' + }) + constructor() { try { void esbuild.initialize({ worker: true, wasmURL: wasm }).then(() => { @@ -15,20 +23,180 @@ class Compiler { } transform = (code: string, loader: Loader) => - new Promise((resolve) => { + new Promise((resolve) => { if (this.init) { - resolve(true) + resolve() return } const timer = setInterval(() => { if (this.init) { clearInterval(timer) - resolve(true) + resolve() } }, 100) }).then(() => { return esbuild.transform(code, { loader }) }) + + compile = (files: IFiles, importMap: IImportMap) => + new Promise((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(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 diff --git a/src/components/Playground/files.ts b/src/components/Playground/files.ts index 9b7f4f2..69a1396 100644 --- a/src/components/Playground/files.ts +++ b/src/components/Playground/files.ts @@ -9,6 +9,8 @@ export const MAIN_FILE_NAME = 'App.tsx' export const IMPORT_MAP_FILE_NAME = 'import-map.json' export const ENTRY_FILE_NAME = 'main.tsx' +export const getFileNameList = (files: IFiles) => Object.keys(files) + export const fileNameToLanguage = (name: string): ILanguage => { const suffix = name.split('.').pop() || '' if (['js', 'jsx'].includes(suffix)) return 'javascript' @@ -55,7 +57,7 @@ export const getFilesFromUrl = () => { export const getModuleFile = (files: IFiles, moduleName: string) => { let _moduleName = moduleName.split('./').pop() || '' if (!_moduleName.includes('.')) { - const realModuleName = Object.keys(files).find((key) => + const realModuleName = getFileNameList(files).find((key) => key.split('.').includes(_moduleName) ) if (realModuleName) _moduleName = realModuleName diff --git a/src/components/Playground/shared.ts b/src/components/Playground/shared.ts index dfcde1e..0e07563 100644 --- a/src/components/Playground/shared.ts +++ b/src/components/Playground/shared.ts @@ -13,6 +13,10 @@ export interface IFiles { [key: string]: IFile } +export interface IImportMap { + imports: Record +} + export type ITheme = 'light' | 'vs-dark' export type IEditorOptions = editor.IStandaloneEditorConstructionOptions diff --git a/src/pages/OnlineEditor.tsx b/src/pages/OnlineEditor.tsx index be818c9..07c8e0b 100644 --- a/src/pages/OnlineEditor.tsx +++ b/src/pages/OnlineEditor.tsx @@ -23,7 +23,7 @@ const OnlineEditor: React.FC = () => { onRenameFile={(_, __, files) => setFiles(files)} onChangeFileContent={(_, __, files) => setFiles(files)} /> - +