Rename ReactPlayground to Playground
This commit is contained in:
100
src/components/Playground/CodeEditor/Editor/ata.ts
Normal file
100
src/components/Playground/CodeEditor/Editor/ata.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { ATABootstrapConfig, setupTypeAcquisition } from '@typescript/ata'
|
||||
|
||||
type DelegateListener = Required<{
|
||||
[k in keyof ATABootstrapConfig['delegate']]: Set<NonNullable<ATABootstrapConfig['delegate'][k]>>
|
||||
}>
|
||||
|
||||
const createDelegate = (): DelegateListener => {
|
||||
return {
|
||||
receivedFile: new Set(),
|
||||
progress: new Set(),
|
||||
errorMessage: new Set(),
|
||||
finished: new Set(),
|
||||
started: new Set()
|
||||
}
|
||||
}
|
||||
|
||||
const delegateListener = createDelegate()
|
||||
|
||||
type InferSet<T> = T extends Set<infer U> ? U : never
|
||||
|
||||
export interface TypeHelper {
|
||||
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 ata = setupTypeAcquisition({
|
||||
projectName: 'monaco-ts',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
typescript: ts,
|
||||
logger: console,
|
||||
fetcher: (input, init) => {
|
||||
try {
|
||||
return fetch(input, init)
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error)
|
||||
}
|
||||
return new Promise(() => {})
|
||||
},
|
||||
delegate: {
|
||||
receivedFile: (code, path) => {
|
||||
delegateListener.receivedFile.forEach((fn) => fn(code, path))
|
||||
},
|
||||
started: () => {
|
||||
delegateListener.started.forEach((fn) => fn())
|
||||
},
|
||||
progress: (downloaded, estimatedTotal) => {
|
||||
delegateListener.progress.forEach((fn) => fn(downloaded, estimatedTotal))
|
||||
},
|
||||
finished: (files) => {
|
||||
delegateListener.finished.forEach((fn) => fn(files))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const acquireType = (code: string) => ata(code)
|
||||
|
||||
const addListener = <T extends keyof DelegateListener>(
|
||||
event: T,
|
||||
handler: InferSet<DelegateListener[T]>
|
||||
) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
delegateListener[event].add(handler)
|
||||
}
|
||||
|
||||
const removeListener = <T extends keyof DelegateListener>(
|
||||
event: T,
|
||||
handler: InferSet<DelegateListener[T]>
|
||||
) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
delegateListener[event].delete(handler)
|
||||
}
|
||||
|
||||
const dispose = () => {
|
||||
for (const key in delegateListener) {
|
||||
delegateListener[key as keyof DelegateListener].clear()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
acquireType,
|
||||
addListener,
|
||||
removeListener,
|
||||
dispose
|
||||
}
|
||||
}
|
||||
43
src/components/Playground/CodeEditor/Editor/editor.scss
Normal file
43
src/components/Playground/CodeEditor/Editor/editor.scss
Normal file
@@ -0,0 +1,43 @@
|
||||
.monaco-editor-light {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background-color: var(--border);
|
||||
|
||||
.jsx-tag-angle-bracket {
|
||||
color: #800000;
|
||||
}
|
||||
|
||||
.jsx-text {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.jsx-tag-name {
|
||||
color: #800000;
|
||||
}
|
||||
|
||||
.jsx-tag-attribute-key {
|
||||
color: #f00;
|
||||
}
|
||||
}
|
||||
|
||||
.monaco-editor-vs-dark {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background-color: var(--border);
|
||||
|
||||
.jsx-tag-angle-bracket {
|
||||
color: #808080;
|
||||
}
|
||||
|
||||
.jsx-text {
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.jsx-tag-name {
|
||||
color: #569cd6;
|
||||
}
|
||||
|
||||
.jsx-tag-attribute-key {
|
||||
color: #9cdcfe;
|
||||
}
|
||||
}
|
||||
109
src/components/Playground/CodeEditor/Editor/hooks.ts
Normal file
109
src/components/Playground/CodeEditor/Editor/hooks.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { editor, IPosition, Selection } from 'monaco-editor'
|
||||
import ScrollType = editor.ScrollType
|
||||
import { Monaco } from '@monaco-editor/react'
|
||||
import { getWorker, MonacoJsxSyntaxHighlight } from 'monaco-jsx-syntax-highlight'
|
||||
import { createATA, TypeHelper } from '@/components/Playground/CodeEditor/Editor/ata'
|
||||
|
||||
export const useEditor = () => {
|
||||
const doOpenEditor = (
|
||||
editor: editor.IStandaloneCodeEditor,
|
||||
input: { options: { selection: Selection } }
|
||||
) => {
|
||||
const selection = input.options ? input.options.selection : null
|
||||
if (selection) {
|
||||
if (
|
||||
typeof selection.endLineNumber === 'number' &&
|
||||
typeof selection.endColumn === 'number'
|
||||
) {
|
||||
editor.setSelection(selection)
|
||||
editor.revealRangeInCenter(selection, ScrollType.Immediate)
|
||||
} else {
|
||||
const position: IPosition = {
|
||||
lineNumber: selection.startLineNumber,
|
||||
column: selection.startColumn
|
||||
}
|
||||
editor.setPosition(position)
|
||||
editor.revealPositionInCenter(position, ScrollType.Immediate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadJsxSyntaxHighlight = (editor: editor.IStandaloneCodeEditor, monaco: Monaco) => {
|
||||
const monacoJsxSyntaxHighlight = new MonacoJsxSyntaxHighlight(getWorker(), monaco)
|
||||
const { highlighter, dispose } = monacoJsxSyntaxHighlight.highlighterBuilder({ editor })
|
||||
|
||||
editor.onDidChangeModelContent(() => {
|
||||
highlighter()
|
||||
})
|
||||
highlighter()
|
||||
|
||||
return { highlighter, dispose }
|
||||
}
|
||||
|
||||
const autoLoadExtraLib = async (
|
||||
editor: editor.IStandaloneCodeEditor,
|
||||
monaco: Monaco,
|
||||
defaultValue: string,
|
||||
onWatch: (typeHelper: TypeHelper) => () => void
|
||||
) => {
|
||||
const typeHelper = await createATA()
|
||||
|
||||
onWatch(typeHelper)
|
||||
|
||||
editor.onDidChangeModelContent(() => {
|
||||
typeHelper.acquireType(editor.getValue())
|
||||
})
|
||||
|
||||
const addLibraryToRuntime = (code: string, path: string) => {
|
||||
monaco.languages.typescript.typescriptDefaults.addExtraLib(code, `file://${path}`)
|
||||
}
|
||||
|
||||
typeHelper.addListener('receivedFile', addLibraryToRuntime)
|
||||
typeHelper.acquireType(defaultValue)
|
||||
|
||||
return typeHelper
|
||||
}
|
||||
|
||||
return {
|
||||
doOpenEditor,
|
||||
loadJsxSyntaxHighlight,
|
||||
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
|
||||
}
|
||||
}
|
||||
108
src/components/Playground/CodeEditor/Editor/index.tsx
Normal file
108
src/components/Playground/CodeEditor/Editor/index.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React from 'react'
|
||||
import { editor, Selection } from 'monaco-editor'
|
||||
import MonacoEditor, { Monaco } from '@monaco-editor/react'
|
||||
import '@/components/Playground/CodeEditor/Editor/editor.scss'
|
||||
import { IEditorOptions, IFiles, ITheme } from '@/components/Playground/shared'
|
||||
import { fileNameToLanguage } from '@/components/Playground/files'
|
||||
import { useEditor, useTypesProgress } from '@/components/Playground/CodeEditor/Editor/hooks'
|
||||
import { MonacoEditorConfig } from '@/components/Playground/CodeEditor/Editor/monacoConfig'
|
||||
|
||||
interface EditorProps {
|
||||
files?: IFiles
|
||||
selectedFileName?: string
|
||||
readonly?: boolean
|
||||
onChange?: (code: string | undefined) => void
|
||||
options?: IEditorOptions
|
||||
theme?: ITheme
|
||||
onJumpFile?: (fileName: string) => void
|
||||
}
|
||||
|
||||
const Editor: React.FC<EditorProps> = ({
|
||||
files = {},
|
||||
selectedFileName = '',
|
||||
readonly,
|
||||
theme,
|
||||
onChange,
|
||||
options,
|
||||
onJumpFile
|
||||
}) => {
|
||||
const editorRef = useRef<editor.IStandaloneCodeEditor>()
|
||||
const { doOpenEditor, loadJsxSyntaxHighlight, autoLoadExtraLib } = useEditor()
|
||||
const jsxSyntaxHighlightRef = useRef<{
|
||||
highlighter: (code?: string | undefined) => void
|
||||
dispose: () => void
|
||||
}>({
|
||||
highlighter: () => undefined,
|
||||
dispose: () => undefined
|
||||
})
|
||||
const { onWatch } = useTypesProgress()
|
||||
const file = files[selectedFileName] ?? { name: 'Untitled' }
|
||||
|
||||
const handleOnEditorDidMount = (editor: editor.IStandaloneCodeEditor, monaco: Monaco) => {
|
||||
editorRef.current = editor
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
void editor.getAction('editor.action.formatDocument')?.run()
|
||||
})
|
||||
|
||||
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
|
||||
jsx: monaco.languages.typescript.JsxEmit.Preserve,
|
||||
esModuleInterop: true
|
||||
})
|
||||
|
||||
files &&
|
||||
Object.entries(files).forEach(([key]) => {
|
||||
if (!monaco.editor.getModel(monaco.Uri.parse(`file:///${key}`))) {
|
||||
monaco.editor.createModel(
|
||||
files[key].value,
|
||||
fileNameToLanguage(key),
|
||||
monaco.Uri.parse(`file:///${key}`)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
editor['_codeEditorService'].doOpenEditor = function (
|
||||
editor: editor.IStandaloneCodeEditor,
|
||||
input: { options: { selection: Selection }; resource: { path: string } }
|
||||
) {
|
||||
const path = input.resource.path
|
||||
if (!path.startsWith('/node_modules/')) {
|
||||
onJumpFile?.(path.replace('/', ''))
|
||||
doOpenEditor(editor, input)
|
||||
}
|
||||
}
|
||||
|
||||
jsxSyntaxHighlightRef.current = loadJsxSyntaxHighlight(editor, monaco)
|
||||
|
||||
void autoLoadExtraLib(editor, monaco, file.value, onWatch)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
editorRef.current?.focus()
|
||||
jsxSyntaxHighlightRef?.current?.highlighter?.()
|
||||
}, [file.name])
|
||||
|
||||
return (
|
||||
<>
|
||||
<MonacoEditor
|
||||
theme={theme}
|
||||
path={file.name}
|
||||
className={`monaco-editor-${theme ?? 'light'}`}
|
||||
language={file.language}
|
||||
value={file.value}
|
||||
onChange={onChange}
|
||||
onMount={handleOnEditorDidMount}
|
||||
options={{
|
||||
...MonacoEditorConfig,
|
||||
...options,
|
||||
theme: undefined,
|
||||
readOnly: readonly
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Editor
|
||||
34
src/components/Playground/CodeEditor/Editor/monacoConfig.ts
Normal file
34
src/components/Playground/CodeEditor/Editor/monacoConfig.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { editor } from 'monaco-editor'
|
||||
|
||||
export const MonacoEditorConfig: editor.IStandaloneEditorConstructionOptions = {
|
||||
automaticLayout: true,
|
||||
cursorBlinking: 'smooth',
|
||||
fontLigatures: true,
|
||||
formatOnPaste: true,
|
||||
formatOnType: true,
|
||||
fontSize: 14,
|
||||
showDeprecated: true,
|
||||
showUnused: true,
|
||||
showFoldingControls: 'mouseover',
|
||||
scrollBeyondLastLine: false,
|
||||
minimap: {
|
||||
enabled: false
|
||||
},
|
||||
inlineSuggest: {
|
||||
enabled: false
|
||||
},
|
||||
fixedOverflowWidgets: true,
|
||||
smoothScrolling: true,
|
||||
smartSelect: {
|
||||
selectSubwords: true,
|
||||
selectLeadingAndTrailingWhitespace: true
|
||||
},
|
||||
tabSize: 2,
|
||||
overviewRulerBorder: false, // 不要滚动条的边框
|
||||
// 滚动条设置
|
||||
scrollbar: {
|
||||
verticalScrollbarSize: 6, // 竖滚动条
|
||||
horizontalScrollbarSize: 6 // 横滚动条
|
||||
}
|
||||
// lineNumbers: 'off', // 隐藏控制行号
|
||||
}
|
||||
139
src/components/Playground/CodeEditor/FileSelector/Item.tsx
Normal file
139
src/components/Playground/CodeEditor/FileSelector/Item.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React from 'react'
|
||||
|
||||
interface ItemProps {
|
||||
className?: string
|
||||
readonly?: boolean
|
||||
creating?: boolean
|
||||
value: string
|
||||
active?: boolean
|
||||
hasEditing?: boolean
|
||||
setHasEditing?: React.Dispatch<React.SetStateAction<boolean>>
|
||||
onOk?: (fileName: string) => void
|
||||
onCancel?: () => void
|
||||
onRemove?: (fileName: string) => void
|
||||
onClick?: () => void
|
||||
onValidate?: (newFileName: string, oldFileName: string) => boolean
|
||||
}
|
||||
|
||||
const Item: React.FC<ItemProps> = ({
|
||||
className,
|
||||
readonly = false,
|
||||
value,
|
||||
active = false,
|
||||
hasEditing,
|
||||
setHasEditing,
|
||||
onOk,
|
||||
onCancel,
|
||||
onRemove,
|
||||
onClick,
|
||||
onValidate,
|
||||
...prop
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [fileName, setFileName] = useState(value)
|
||||
const [creating, setCreating] = useState(prop.creating)
|
||||
|
||||
const handleOnClick = () => {
|
||||
if (hasEditing) {
|
||||
return
|
||||
}
|
||||
|
||||
onClick?.()
|
||||
}
|
||||
|
||||
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) {
|
||||
inputRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
if (fileName === value && active) {
|
||||
setCreating(false)
|
||||
setHasEditing?.(false)
|
||||
return
|
||||
}
|
||||
|
||||
onOk?.(fileName)
|
||||
setCreating(false)
|
||||
setHasEditing?.(false)
|
||||
}
|
||||
|
||||
const cancelNameFile = () => {
|
||||
setFileName(value)
|
||||
setCreating(false)
|
||||
setHasEditing?.(false)
|
||||
onCancel?.()
|
||||
}
|
||||
|
||||
const handleOnDoubleClick = () => {
|
||||
if (readonly || creating || hasEditing) {
|
||||
return
|
||||
}
|
||||
|
||||
setCreating(true)
|
||||
setHasEditing?.(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 (hasEditing) {
|
||||
return
|
||||
}
|
||||
if (confirm(`确定删除文件 ${value} ?`)) {
|
||||
onRemove?.(value)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`tab-item${active ? ' active' : ''}${className ? ` ${className}` : ''}`}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
{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,86 @@
|
||||
[data-component=playground-file-selector].tab{
|
||||
display: flex;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
185
src/components/Playground/CodeEditor/FileSelector/index.tsx
Normal file
185
src/components/Playground/CodeEditor/FileSelector/index.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
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 Item from '@/components/Playground/CodeEditor/FileSelector/Item'
|
||||
import HideScrollbar, { HideScrollbarElement } from '@/components/common/HideScrollbar'
|
||||
import FlexBox from '@/components/common/FlexBox'
|
||||
|
||||
interface FileSelectorProps {
|
||||
files?: IFiles
|
||||
onChange?: (fileName: string) => void
|
||||
onError?: (msg: string) => void
|
||||
readonly?: boolean
|
||||
notRemovableFiles?: 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,
|
||||
notRemovableFiles = [],
|
||||
onRemoveFile,
|
||||
onAddFile,
|
||||
onUpdateFileName,
|
||||
selectedFileName = ''
|
||||
}) => {
|
||||
const hideScrollbarRef = useRef<HideScrollbarElement>(null)
|
||||
const [tabs, setTabs] = useState<string[]>([])
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [hasEditing, setHasEditing] = 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)
|
||||
setTimeout(() => {
|
||||
hideScrollbarRef.current?.scrollRight(1000)
|
||||
})
|
||||
}
|
||||
|
||||
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?.(value, item)
|
||||
}
|
||||
|
||||
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)
|
||||
if (fileName === selectedFileName) {
|
||||
const index = Object.keys(files).indexOf(fileName) - 1
|
||||
if (index >= 0) {
|
||||
handleOnClickTab(Object.keys(files)[index])
|
||||
} else {
|
||||
handleOnClickTab(Object.keys(files)[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
Object.keys(files).length
|
||||
? setTabs(
|
||||
Object.keys(files).filter(
|
||||
(item) =>
|
||||
![IMPORT_MAP_FILE_NAME, ENTRY_FILE_NAME].includes(item) &&
|
||||
!files[item].hidden
|
||||
)
|
||||
)
|
||||
: setTabs([])
|
||||
}, [files])
|
||||
|
||||
return (
|
||||
<>
|
||||
<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}
|
||||
creating={creating}
|
||||
readonly={readonly || notRemovableFiles.includes(item)}
|
||||
hasEditing={hasEditing}
|
||||
setHasEditing={setHasEditing}
|
||||
onValidate={handleOnValidateTab}
|
||||
onOk={(name) => handleOnSaveTab(name, item)}
|
||||
onCancel={handleOnCancel}
|
||||
onRemove={handleOnRemove}
|
||||
onClick={() => handleOnClickTab(item)}
|
||||
/>
|
||||
))}
|
||||
{!readonly && (
|
||||
<Item
|
||||
className={'tab-item-add'}
|
||||
value={'+'}
|
||||
onClick={addTab}
|
||||
readonly
|
||||
/>
|
||||
)}
|
||||
<div className={'tabs-margin-right'}>
|
||||
<div />
|
||||
</div>
|
||||
</FlexBox>
|
||||
</HideScrollbar>
|
||||
</div>
|
||||
{files[IMPORT_MAP_FILE_NAME] && (
|
||||
<div className={'sticky'}>
|
||||
<Item
|
||||
value={'Import Map'}
|
||||
active={selectedFileName === IMPORT_MAP_FILE_NAME}
|
||||
onClick={editImportMap}
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileSelector
|
||||
5
src/components/Playground/CodeEditor/code-editor.scss
Normal file
5
src/components/Playground/CodeEditor/code-editor.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
[data-component=playground-code-editor] {
|
||||
section {
|
||||
height: 0 !important;
|
||||
}
|
||||
}
|
||||
136
src/components/Playground/CodeEditor/index.tsx
Normal file
136
src/components/Playground/CodeEditor/index.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React from 'react'
|
||||
import _ from 'lodash'
|
||||
import '@/components/Playground/CodeEditor/code-editor.scss'
|
||||
import { IEditorOptions, IFiles, ITheme } from '@/components/Playground/shared'
|
||||
import { fileNameToLanguage } from '@/components/Playground/files'
|
||||
import FileSelector from '@/components/Playground/CodeEditor/FileSelector'
|
||||
import Editor from '@/components/Playground/CodeEditor/Editor'
|
||||
import FitFullscreen from '@/components/common/FitFullscreen'
|
||||
import FlexBox from '@/components/common/FlexBox'
|
||||
|
||||
interface CodeEditorProps {
|
||||
theme?: ITheme
|
||||
files: IFiles
|
||||
readonly?: boolean
|
||||
readonlyFiles?: string[]
|
||||
notRemovable?: string[]
|
||||
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> = ({
|
||||
theme,
|
||||
files,
|
||||
readonly,
|
||||
readonlyFiles,
|
||||
notRemovable,
|
||||
options,
|
||||
onSelectedFileChange,
|
||||
onAddFile,
|
||||
onRemoveFile,
|
||||
onRenameFile,
|
||||
onChangeFileContent,
|
||||
...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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const { [oldFileName]: value, ...rest } = files
|
||||
const newFile: IFiles = {
|
||||
[newFileName]: {
|
||||
...value,
|
||||
language: fileNameToLanguage(newFileName),
|
||||
name: newFileName
|
||||
}
|
||||
}
|
||||
const newFiles: IFiles = { ...rest, ...newFile }
|
||||
|
||||
onRenameFile?.(newFileName, oldFileName, newFiles)
|
||||
}
|
||||
|
||||
const handleOnChangeFileContent = (code = '') => {
|
||||
if (!files[onSelectedFileChange ? props.selectedFileName : selectedFileName]) {
|
||||
return
|
||||
}
|
||||
const clone = _.cloneDeep(files)
|
||||
clone[onSelectedFileChange ? props.selectedFileName : selectedFileName].value = code ?? ''
|
||||
onChangeFileContent?.(
|
||||
code,
|
||||
onSelectedFileChange ? props.selectedFileName : selectedFileName,
|
||||
clone
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FitFullscreen data-component={'playground-code-editor'}>
|
||||
<FlexBox style={{ height: '100%' }}>
|
||||
<FileSelector
|
||||
files={files}
|
||||
readonly={readonly}
|
||||
notRemovableFiles={notRemovable}
|
||||
selectedFileName={
|
||||
onSelectedFileChange ? props.selectedFileName : selectedFileName
|
||||
}
|
||||
onChange={handleOnChangeSelectedFile}
|
||||
onRemoveFile={handleOnRemoveFile}
|
||||
onUpdateFileName={handleOnUpdateFileName}
|
||||
onAddFile={handleOnAddFile}
|
||||
/>
|
||||
<Editor
|
||||
theme={theme}
|
||||
selectedFileName={
|
||||
onSelectedFileChange ? props.selectedFileName : selectedFileName
|
||||
}
|
||||
files={files}
|
||||
options={options}
|
||||
readonly={
|
||||
readonly ||
|
||||
readonlyFiles?.includes(
|
||||
onSelectedFileChange ? props.selectedFileName : selectedFileName
|
||||
) ||
|
||||
!files[onSelectedFileChange ? props.selectedFileName : selectedFileName]
|
||||
}
|
||||
onChange={handleOnChangeFileContent}
|
||||
/>
|
||||
</FlexBox>
|
||||
</FitFullscreen>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default CodeEditor
|
||||
143
src/components/Playground/files.ts
Normal file
143
src/components/Playground/files.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { strFromU8, strToU8, unzlibSync, zlibSync } from 'fflate'
|
||||
import importMap from '@/components/Playground/template/import-map.json?raw'
|
||||
import AppCss from '@/components/Playground/template/src/App.css?raw'
|
||||
import App from '@/components/Playground/template/src/App.tsx?raw'
|
||||
import main from '@/components/Playground/template/src/main.tsx?raw'
|
||||
import { ICustomFiles, IFiles, IImportMap } from '@/components/Playground/shared'
|
||||
|
||||
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 fileNameToLanguage = (name: string) => {
|
||||
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'
|
||||
return 'javascript'
|
||||
}
|
||||
|
||||
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])) as IFiles
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
export const initFiles: IFiles = getFilesFromUrl() || {
|
||||
[ENTRY_FILE_NAME]: {
|
||||
name: ENTRY_FILE_NAME,
|
||||
language: fileNameToLanguage(ENTRY_FILE_NAME),
|
||||
value: main
|
||||
},
|
||||
[MAIN_FILE_NAME]: {
|
||||
name: MAIN_FILE_NAME,
|
||||
language: fileNameToLanguage(MAIN_FILE_NAME),
|
||||
value: App
|
||||
},
|
||||
'App.css': {
|
||||
name: 'App.css',
|
||||
language: 'css',
|
||||
value: AppCss
|
||||
},
|
||||
[IMPORT_MAP_FILE_NAME]: {
|
||||
name: IMPORT_MAP_FILE_NAME,
|
||||
language: fileNameToLanguage(IMPORT_MAP_FILE_NAME),
|
||||
value: importMap
|
||||
}
|
||||
}
|
||||
|
||||
export const reactTemplateFiles = {
|
||||
[ENTRY_FILE_NAME]: {
|
||||
name: ENTRY_FILE_NAME,
|
||||
language: fileNameToLanguage(ENTRY_FILE_NAME),
|
||||
value: main
|
||||
},
|
||||
[IMPORT_MAP_FILE_NAME]: {
|
||||
name: IMPORT_MAP_FILE_NAME,
|
||||
language: fileNameToLanguage(IMPORT_MAP_FILE_NAME),
|
||||
value: importMap
|
||||
}
|
||||
}
|
||||
8
src/components/Playground/index.tsx
Normal file
8
src/components/Playground/index.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from 'react'
|
||||
import { IPlayground } from '@/components/Playground/shared'
|
||||
|
||||
const ReactPlayground: React.FC<IPlayground> = () => {
|
||||
return <></>
|
||||
}
|
||||
|
||||
export default ReactPlayground
|
||||
80
src/components/Playground/shared.ts
Normal file
80
src/components/Playground/shared.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from 'react'
|
||||
import { editor } from 'monaco-editor'
|
||||
|
||||
export type ILanguage = 'javascript' | 'typescript' | 'json' | 'css' | 'xml'
|
||||
|
||||
export interface IFile {
|
||||
name: string
|
||||
value: string
|
||||
language: ILanguage
|
||||
active?: boolean
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
export interface IFiles {
|
||||
[key: string]: IFile
|
||||
}
|
||||
|
||||
export type ITheme = 'light' | 'vs-dark'
|
||||
|
||||
export type IImportMap = { imports: Record<string, string> }
|
||||
|
||||
export interface ICustomFiles {
|
||||
[key: string]:
|
||||
| string
|
||||
| {
|
||||
code: string
|
||||
active?: boolean
|
||||
hidden?: boolean
|
||||
}
|
||||
}
|
||||
export type IEditorOptions = editor.IStandaloneEditorConstructionOptions
|
||||
export interface IEditorContainer {
|
||||
showFileSelector?: boolean
|
||||
fileSelectorReadOnly?: boolean
|
||||
options?: IEditorOptions
|
||||
}
|
||||
|
||||
export interface IOutput {
|
||||
showCompileOutput?: boolean
|
||||
}
|
||||
|
||||
export interface ISplitPane {
|
||||
children?: React.ReactNode[]
|
||||
defaultSizes?: number[]
|
||||
}
|
||||
|
||||
export type IPlayground = {
|
||||
width?: string | number
|
||||
height?: string | number
|
||||
theme?: ITheme
|
||||
importMap?: IImportMap
|
||||
files?: ICustomFiles
|
||||
options?: {
|
||||
lineNumbers?: boolean
|
||||
fontSize?: number
|
||||
tabSize?: number
|
||||
}
|
||||
showHeader?: boolean
|
||||
border?: boolean
|
||||
onFilesChange?: (url: string) => void
|
||||
saveOnUrl?: boolean
|
||||
autorun?: boolean
|
||||
// recompileDelay
|
||||
} & Omit<IEditorContainer, 'options'> &
|
||||
IOutput &
|
||||
ISplitPane
|
||||
|
||||
export interface IPlaygroundContext {
|
||||
files: IFiles
|
||||
filesHash: string
|
||||
theme: ITheme
|
||||
selectedFileName: string
|
||||
setSelectedFileName: (fileName: string) => void
|
||||
setTheme: (theme: ITheme) => void
|
||||
setFiles: (files: IFiles) => void
|
||||
addFile: (fileName: string) => void
|
||||
removeFile: (fileName: string) => void
|
||||
changeFileName: (oldFieldName: string, newFieldName: string) => void
|
||||
changeTheme: (theme: ITheme) => void
|
||||
}
|
||||
18
src/components/Playground/template/.eslintrc.cjs
Normal file
18
src/components/Playground/template/.eslintrc.cjs
Normal file
@@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
24
src/components/Playground/template/.gitignore
vendored
Normal file
24
src/components/Playground/template/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
28
src/components/Playground/template/README.md
Normal file
28
src/components/Playground/template/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
```
|
||||
|
||||
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
||||
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
||||
gitignore
|
||||
6
src/components/Playground/template/import-map.json
Normal file
6
src/components/Playground/template/import-map.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"imports": {
|
||||
"react": "https://esm.sh/react@18.2.0",
|
||||
"react-dom/client": "https://esm.sh/react-dom@18.2.0"
|
||||
}
|
||||
}
|
||||
13
src/components/Playground/template/index.html
Normal file
13
src/components/Playground/template/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
src/components/Playground/template/package.json
Normal file
28
src/components/Playground/template/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "react-playground",
|
||||
"author": "fewismuch",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.3",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.4.5"
|
||||
}
|
||||
}
|
||||
1
src/components/Playground/template/public/vite.svg
Normal file
1
src/components/Playground/template/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
65
src/components/Playground/template/src/App.css
Normal file
65
src/components/Playground/template/src/App.css
Normal file
@@ -0,0 +1,65 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: rgb(255 255 255 / 87%);
|
||||
text-rendering: optimizelegibility;
|
||||
text-size-adjust: 100%;
|
||||
background-color: #242424;
|
||||
color-scheme: light dark;
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
padding: 2rem;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.6em 1.2em;
|
||||
font-family: inherit;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background-color: #1a1a1a;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
17
src/components/Playground/template/src/App.tsx
Normal file
17
src/components/Playground/template/src/App.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useState } from 'react'
|
||||
import './App.css'
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Hello World</h1>
|
||||
<div className='card'>
|
||||
<button onClick={() => setCount((count) => count + 1)}>count is {count}</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
10
src/components/Playground/template/src/main.tsx
Normal file
10
src/components/Playground/template/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
|
||||
import App from './App'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
25
src/components/Playground/template/tsconfig.json
Normal file
25
src/components/Playground/template/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
src/components/Playground/template/tsconfig.node.json
Normal file
10
src/components/Playground/template/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
7
src/components/Playground/template/vite.config.js
Normal file
7
src/components/Playground/template/vite.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()]
|
||||
})
|
||||
15
src/components/Playground/utils.ts
Normal file
15
src/components/Playground/utils.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ITheme } from '@/components/Playground/shared'
|
||||
|
||||
const STORAGE_DARK_THEME = 'react-playground-prefer-dark'
|
||||
|
||||
export const setPlaygroundTheme = (theme: ITheme) => {
|
||||
localStorage.setItem(STORAGE_DARK_THEME, String(theme === 'vs-dark'))
|
||||
document
|
||||
.querySelectorAll('div[data-id="react-playground"]')
|
||||
?.forEach((item) => item.setAttribute('class', theme))
|
||||
}
|
||||
|
||||
export const getPlaygroundTheme = (): ITheme => {
|
||||
const isDarkTheme = JSON.parse(localStorage.getItem(STORAGE_DARK_THEME) || 'false') as ITheme
|
||||
return isDarkTheme ? 'vs-dark' : 'light'
|
||||
}
|
||||
Reference in New Issue
Block a user