diff --git a/src/components/ReactPlayground/CodeEditor/Editor/index.tsx b/src/components/ReactPlayground/CodeEditor/Editor/index.tsx index c3887bf..6eb1a40 100644 --- a/src/components/ReactPlayground/CodeEditor/Editor/index.tsx +++ b/src/components/ReactPlayground/CodeEditor/Editor/index.tsx @@ -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' diff --git a/src/components/ReactPlayground/CodeEditor/FileSelector/Item.tsx b/src/components/ReactPlayground/CodeEditor/FileSelector/Item.tsx index bcb447c..209c245 100644 --- a/src/components/ReactPlayground/CodeEditor/FileSelector/Item.tsx +++ b/src/components/ReactPlayground/CodeEditor/FileSelector/Item.tsx @@ -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 = ({ + className, readonly = false, value, active = false, @@ -105,7 +107,10 @@ const Item: React.FC = ({ }, []) return ( -
+
{creating ? (
* { + 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; } } + } \ No newline at end of file diff --git a/src/components/ReactPlayground/CodeEditor/FileSelector/index.tsx b/src/components/ReactPlayground/CodeEditor/FileSelector/index.tsx index 3eee08a..701075d 100644 --- a/src/components/ReactPlayground/CodeEditor/FileSelector/index.tsx +++ b/src/components/ReactPlayground/CodeEditor/FileSelector/index.tsx @@ -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 = ({ onUpdateFileName, selectedFileName = '' }) => { + const hideScrollbarRef = useRef(null) const [tabs, setTabs] = useState([]) const [creating, setCreating] = useState(false) const [hasEditing, setHasEditing] = useState(false) @@ -47,6 +50,9 @@ const FileSelector: React.FC = ({ const addTab = () => { setTabs([...tabs, getMaxSequenceTabName(tabs)]) setCreating(true) + setTimeout(() => { + hideScrollbarRef.current?.scrollRight(1000) + }) } const handleOnCancel = () => { @@ -126,27 +132,48 @@ const FileSelector: React.FC = ({ return ( <> -
- {tabs.map((item, index) => ( +
+
+ + + {tabs.map((item, index) => ( + handleOnSaveTab(name, item)} + onCancel={handleOnCancel} + onRemove={handleOnRemove} + onClick={() => handleOnClickTab(item)} + /> + ))} + {!readonly && ( + + )} +
+
+
+ + +
+
handleOnSaveTab(name, item)} - onCancel={handleOnCancel} - onRemove={handleOnRemove} - onClick={() => handleOnClickTab(item)} + value={'Import Map'} + active={selectedFileName === IMPORT_MAP_FILE_NAME} + onClick={editImportMap} + readonly /> - ))} +
) diff --git a/src/components/ReactPlayground/CodeEditor/code-editor.scss b/src/components/ReactPlayground/CodeEditor/code-editor.scss new file mode 100644 index 0000000..9ff6eaf --- /dev/null +++ b/src/components/ReactPlayground/CodeEditor/code-editor.scss @@ -0,0 +1,5 @@ +[data-component=playground-code-editor] { + section { + height: 0 !important; + } +} \ No newline at end of file diff --git a/src/components/ReactPlayground/CodeEditor/index.tsx b/src/components/ReactPlayground/CodeEditor/index.tsx index a59640c..7c9f8e2 100644 --- a/src/components/ReactPlayground/CodeEditor/index.tsx +++ b/src/components/ReactPlayground/CodeEditor/index.tsx @@ -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 = ({ @@ -28,8 +31,10 @@ const CodeEditor: React.FC = ({ notRemovable, options, onSelectedFileChange, + onAddFile, onRemoveFile, onRenameFile, + onChangeFileContent, ...props }) => { const [selectedFileName, setSelectedFileName] = useState(props.selectedFileName) @@ -48,6 +53,17 @@ const CodeEditor: React.FC = ({ 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 = ({ 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 ( <> - + = ({ onChange={handleOnChangeSelectedFile} onRemoveFile={handleOnRemoveFile} onUpdateFileName={handleOnUpdateFileName} + onAddFile={handleOnAddFile} /> diff --git a/src/components/ReactPlayground/Playground.tsx b/src/components/ReactPlayground/Playground.tsx deleted file mode 100644 index 12780bb..0000000 --- a/src/components/ReactPlayground/Playground.tsx +++ /dev/null @@ -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 = (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] ?
: undefined -} - -export default Playground -*/ diff --git a/src/components/ReactPlayground/Provider.tsx b/src/components/ReactPlayground/Provider.tsx deleted file mode 100644 index 71b11e5..0000000 --- a/src/components/ReactPlayground/Provider.tsx +++ /dev/null @@ -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 = { - selectedFileName: MAIN_FILE_NAME -} - -export const PlaygroundContext = createContext( - initialContext as IPlaygroundContext -) - -interface ProviderProps extends React.PropsWithChildren { - saveOnUrl?: boolean -} - -const Provider: React.FC = ({ children, saveOnUrl }) => { - const [files, setFiles] = useState({}) - 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 ( - - {children} - - ) -} - -export default Provider diff --git a/src/components/ReactPlayground/files.ts b/src/components/ReactPlayground/files.ts index 338df8d..c3d37c3 100644 --- a/src/components/ReactPlayground/files.ts +++ b/src/components/ReactPlayground/files.ts @@ -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) diff --git a/src/components/ReactPlayground/index.tsx b/src/components/ReactPlayground/index.tsx index f417e64..853cfea 100644 --- a/src/components/ReactPlayground/index.tsx +++ b/src/components/ReactPlayground/index.tsx @@ -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 = (props) => { - return ( - <> - - - - - ) +const ReactPlayground: React.FC = () => { + return <> } export default ReactPlayground diff --git a/src/components/ReactPlayground/utils.ts b/src/components/ReactPlayground/utils.ts index 38ac84a..fc87e25 100644 --- a/src/components/ReactPlayground/utils.ts +++ b/src/components/ReactPlayground/utils.ts @@ -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' -} diff --git a/src/pages/OnlineEditor.tsx b/src/pages/OnlineEditor.tsx index 008cc53..342e781 100644 --- a/src/pages/OnlineEditor.tsx +++ b/src/pages/OnlineEditor.tsx @@ -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({ @@ -45,13 +45,14 @@ const OnlineEditor: React.FC = () => { return ( <> setFiles(files)} onRemoveFile={(_, files) => setFiles(files)} onRenameFile={(_, __, files) => setFiles(files)} + onChangeFileContent={(_, __, files) => setFiles(files)} /> )