Rename ReactPlayground to Playground

This commit is contained in:
2024-01-09 11:47:00 +08:00
parent 087b632610
commit f9d0cc2611
28 changed files with 25 additions and 25 deletions

View 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
}
}

View 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;
}
}

View 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
}
}

View 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

View 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', // 隐藏控制行号
}

View 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

View File

@@ -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;
}
}
}

View 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

View File

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

View 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