Complete main UI #37

Merged
FatttSnake merged 192 commits from FatttSnake into dev 2024-02-23 16:31:17 +08:00
12 changed files with 540 additions and 62 deletions
Showing only changes of commit 5709815613 - Show all commits

1
src/assets/svg/close.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1028 1024"><path d="M646.4 512l345.6-345.6c38.4-38.4 38.4-96 0-134.4-38.4-38.4-96-38.4-134.4 0L512 377.6 166.4 32C128-6.4 70.4-6.4 32 32c-38.4 38.4-38.4 96 0 134.4L377.6 512l-345.6 345.6c-38.4 38.4-38.4 96 0 134.4 19.2 19.2 44.8 25.6 70.4 25.6s51.2-6.4 70.4-25.6L512 646.4l345.6 345.6c19.2 19.2 44.8 25.6 70.4 25.6s51.2-6.4 70.4-25.6c38.4-38.4 38.4-96 0-134.4L646.4 512z" /></svg>

After

Width:  |  Height:  |  Size: 433 B

View File

@@ -18,32 +18,47 @@ const delegateListener = createDelegate()
type InferSet<T> = T extends Set<infer U> ? U : never type InferSet<T> = T extends Set<infer U> ? U : never
export const createATA = async () => { export interface TypeHelper {
// @ts-ignore 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 ts = await import('https://esm.sh/typescript@5.3.3')
const ata = setupTypeAcquisition({ const ata = setupTypeAcquisition({
projectName: 'monaco-ts', projectName: 'monaco-ts',
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
typescript: ts, typescript: ts,
logger: console, logger: console,
fetcher: (input, init) => { fetcher: (input, init) => {
let result: any
try { try {
result = fetch(input, init) return fetch(input, init)
} catch (error) { } catch (error) {
console.error('Error fetching data:', error) console.error('Error fetching data:', error)
} }
return result return new Promise(() => {})
}, },
delegate: { delegate: {
receivedFile: (code, path) => { receivedFile: (code, path) => {
delegateListener.receivedFile.forEach((fn) => fn(code, path)) delegateListener.receivedFile.forEach((fn) => fn(code, path))
}, },
progress: (downloaded, estimatedTotal) => {
delegateListener.progress.forEach((fn) => fn(downloaded, estimatedTotal))
},
started: () => { started: () => {
delegateListener.started.forEach((fn) => fn()) delegateListener.started.forEach((fn) => fn())
}, },
progress: (downloaded, estimatedTotal) => {
delegateListener.progress.forEach((fn) => fn(downloaded, estimatedTotal))
},
finished: (files) => { finished: (files) => {
delegateListener.finished.forEach((fn) => fn(files)) delegateListener.finished.forEach((fn) => fn(files))
} }
@@ -56,7 +71,8 @@ export const createATA = async () => {
event: T, event: T,
handler: InferSet<DelegateListener[T]> handler: InferSet<DelegateListener[T]>
) => { ) => {
// @ts-ignore // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
delegateListener[event].add(handler) delegateListener[event].add(handler)
} }
@@ -64,7 +80,8 @@ export const createATA = async () => {
event: T, event: T,
handler: InferSet<DelegateListener[T]> handler: InferSet<DelegateListener[T]>
) => { ) => {
// @ts-ignore // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
delegateListener[event].delete(handler) delegateListener[event].delete(handler)
} }

View File

@@ -2,7 +2,7 @@ import { editor, IPosition, Selection } from 'monaco-editor'
import ScrollType = editor.ScrollType import ScrollType = editor.ScrollType
import { Monaco } from '@monaco-editor/react' import { Monaco } from '@monaco-editor/react'
import { getWorker, MonacoJsxSyntaxHighlight } from 'monaco-jsx-syntax-highlight' import { getWorker, MonacoJsxSyntaxHighlight } from 'monaco-jsx-syntax-highlight'
import { createATA } from '@/components/ReactPlayground/CodeEditor/Editor/ata.ts' import { createATA, TypeHelper } from '@/components/ReactPlayground/CodeEditor/Editor/ata.ts'
export const useEditor = () => { export const useEditor = () => {
const doOpenEditor = ( const doOpenEditor = (
@@ -44,7 +44,7 @@ export const useEditor = () => {
editor: editor.IStandaloneCodeEditor, editor: editor.IStandaloneCodeEditor,
monaco: Monaco, monaco: Monaco,
defaultValue: string, defaultValue: string,
onWatch: any onWatch: (typeHelper: TypeHelper) => () => void
) => { ) => {
const typeHelper = await createATA() const typeHelper = await createATA()
@@ -70,3 +70,40 @@ export const useEditor = () => {
autoLoadExtraLib 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

@@ -2,23 +2,30 @@ import React from 'react'
import { editor, Selection } from 'monaco-editor' import { editor, Selection } from 'monaco-editor'
import MonacoEditor, { Monaco } from '@monaco-editor/react' import MonacoEditor, { Monaco } from '@monaco-editor/react'
import '@/components/ReactPlayground/CodeEditor/Editor/editor.scss' import '@/components/ReactPlayground/CodeEditor/Editor/editor.scss'
import { IEditorOptions, IFile, IFiles, ITheme } from '@/components/ReactPlayground/shared' import { IEditorOptions, IFiles, ITheme } from '@/components/ReactPlayground/shared'
import { MonacoEditorConfig } from '@/components/ReactPlayground/CodeEditor/Editor/monacoConfig' import { MonacoEditorConfig } from '@/components/ReactPlayground/CodeEditor/Editor/monacoConfig'
import { fileNameToLanguage } from '@/components/ReactPlayground/utils' import { fileNameToLanguage } from '@/components/ReactPlayground/utils'
import { useEditor } from '@/components/ReactPlayground/CodeEditor/Editor/hooks' import { useEditor, useTypesProgress } from '@/components/ReactPlayground/CodeEditor/Editor/hooks'
interface EditorProps { interface EditorProps {
file: IFile files?: IFiles
selectedFileName?: string
onChange?: (code: string | undefined) => void onChange?: (code: string | undefined) => void
options?: IEditorOptions options?: IEditorOptions
theme?: ITheme theme?: ITheme
files?: IFiles
onJumpFile?: (fileName: string) => void onJumpFile?: (fileName: string) => void
} }
const Editor: React.FC<EditorProps> = ({ file, files, theme, onChange, options, onJumpFile }) => { const Editor: React.FC<EditorProps> = ({
files = {},
selectedFileName = '',
theme,
onChange,
options,
onJumpFile
}) => {
const editorRef = useRef<editor.IStandaloneCodeEditor>() const editorRef = useRef<editor.IStandaloneCodeEditor>()
const { doOpenEditor, loadJsxSyntaxHighlight } = useEditor() const { doOpenEditor, loadJsxSyntaxHighlight, autoLoadExtraLib } = useEditor()
const jsxSyntaxHighlightRef = useRef<{ const jsxSyntaxHighlightRef = useRef<{
highlighter: (code?: string | undefined) => void highlighter: (code?: string | undefined) => void
dispose: () => void dispose: () => void
@@ -26,6 +33,8 @@ const Editor: React.FC<EditorProps> = ({ file, files, theme, onChange, options,
highlighter: () => undefined, highlighter: () => undefined,
dispose: () => undefined dispose: () => undefined
}) })
const { onWatch } = useTypesProgress()
const file = files[selectedFileName] ?? { name: 'Untitled' }
const handleOnEditorDidMount = (editor: editor.IStandaloneCodeEditor, monaco: Monaco) => { const handleOnEditorDidMount = (editor: editor.IStandaloneCodeEditor, monaco: Monaco) => {
editorRef.current = editor editorRef.current = editor
@@ -64,6 +73,8 @@ const Editor: React.FC<EditorProps> = ({ file, files, theme, onChange, options,
} }
jsxSyntaxHighlightRef.current = loadJsxSyntaxHighlight(editor, monaco) jsxSyntaxHighlightRef.current = loadJsxSyntaxHighlight(editor, monaco)
void autoLoadExtraLib(editor, monaco, file.value, onWatch)
} }
useEffect(() => { useEffect(() => {

View File

@@ -1,34 +1,34 @@
import { editor } from 'monaco-editor' import { editor } from 'monaco-editor'
export const MonacoEditorConfig: editor.IStandaloneEditorConstructionOptions = { export const MonacoEditorConfig: editor.IStandaloneEditorConstructionOptions = {
automaticLayout: true, automaticLayout: true,
cursorBlinking: 'smooth', cursorBlinking: 'smooth',
fontLigatures: true, fontLigatures: true,
formatOnPaste: true, formatOnPaste: true,
formatOnType: true, formatOnType: true,
fontSize: 14, fontSize: 14,
showDeprecated: true, showDeprecated: true,
showUnused: true, showUnused: true,
showFoldingControls: 'mouseover', showFoldingControls: 'mouseover',
scrollBeyondLastLine: false, scrollBeyondLastLine: false,
minimap: { minimap: {
enabled: false, enabled: false
}, },
inlineSuggest: { inlineSuggest: {
enabled: false, enabled: false
}, },
fixedOverflowWidgets: true, fixedOverflowWidgets: true,
smoothScrolling: true, smoothScrolling: true,
smartSelect: { smartSelect: {
selectSubwords: true, selectSubwords: true,
selectLeadingAndTrailingWhitespace: true, selectLeadingAndTrailingWhitespace: true
}, },
tabSize: 2, tabSize: 2,
overviewRulerBorder: false, // 不要滚动条的边框 overviewRulerBorder: false, // 不要滚动条的边框
// 滚动条设置 // 滚动条设置
scrollbar: { scrollbar: {
verticalScrollbarSize: 6, // 竖滚动条 verticalScrollbarSize: 6, // 竖滚动条
horizontalScrollbarSize: 6, // 横滚动条 horizontalScrollbarSize: 6 // 横滚动条
}, }
// lineNumbers: 'off', // 隐藏控制行号 // lineNumbers: 'off', // 隐藏控制行号
} }

View File

@@ -0,0 +1,114 @@
import React from 'react'
interface ItemProps {
readonly?: boolean
creating?: boolean
value: string
active?: boolean
onOk?: (fileName: string) => void
onCancel?: () => void
onRemove?: (fileName: string) => void
onClick?: () => void
onValidate?: (newFileName: string, oldFileName: string) => boolean
}
const Item: React.FC<ItemProps> = ({
readonly = false,
value,
active = false,
onOk,
onCancel,
onRemove,
onClick,
onValidate,
...prop
}) => {
const inputRef = useRef<HTMLInputElement>(null)
const [fileName, setFileName] = useState(value)
const [creating, setCreating] = useState(prop.creating)
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) {
return
}
if (fileName === value && active) {
setCreating(false)
return
}
onOk?.(fileName)
setCreating(false)
}
const cancelNameFile = () => {
setFileName(value)
setCreating(false)
onCancel?.()
}
const handleOnDoubleClick = () => {
if (readonly) {
return
}
setCreating(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 (confirm(`确定删除文件 ${value} `)) {
onRemove?.(value)
}
}
useEffect(() => {
inputRef.current?.focus()
}, [])
return (
<div className={`tab-item${active ? ' active' : ''}`} onClick={onClick}>
{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,54 @@
[data-component=playground-file-selector].tab{
display: flex;
flex-direction: row;
gap: 1px;
padding: 10px 20px 0 20px;
.tab-item {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
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,151 @@
import React from 'react'
import '@/components/ReactPlayground/CodeEditor/FileSelector/file-selector.scss'
import { IFiles } from '@/components/ReactPlayground/shared.ts'
import {
ENTRY_FILE_NAME,
IMPORT_MAP_FILE_NAME,
MAIN_FILE_NAME
} from '@/components/ReactPlayground/files.ts'
import Item from '@/components/ReactPlayground/CodeEditor/FileSelector/Item.tsx'
interface FileSelectorProps {
files?: IFiles
onChange?: (fileName: string) => void
onError?: (msg: string) => void
readonly?: boolean
readonlyFiles?: 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,
readonlyFiles = [],
onRemoveFile,
onAddFile,
onUpdateFileName,
selectedFileName = ''
}) => {
const [tabs, setTabs] = useState<string[]>([])
const [creating, setCreating] = 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)
}
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?.(item, value)
}
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)
handleOnClickTab(Object.keys(files)[Object.keys(files).length - 1])
}
useEffect(() => {
Object.keys(files).length &&
setTabs(
Object.keys(files).filter(
(item) =>
![IMPORT_MAP_FILE_NAME, ENTRY_FILE_NAME].includes(item) &&
!files[item].hidden
)
)
}, [files])
return (
<>
<div
data-component={'playground-file-selector'}
className={'tab'}
style={{ flex: '0 0 auto' }}
>
{tabs.map((item, index) => (
<Item
key={index + item}
value={item}
active={selectedFileName == item}
creating={creating}
readonly={
readonly || readonlyFiles.includes(item) || MAIN_FILE_NAME == item
}
onValidate={handleOnValidateTab}
onOk={(name) => handleOnSaveTab(name, item)}
onCancel={handleOnCancel}
onRemove={handleOnRemove}
onClick={() => handleOnClickTab(item)}
/>
))}
</div>
</>
)
}
export default FileSelector

View File

@@ -0,0 +1,72 @@
import React, { useState } from 'react'
import FileSelector from '@/components/ReactPlayground/CodeEditor/FileSelector'
import Editor from '@/components/ReactPlayground/CodeEditor/Editor'
import { IEditorOptions, IFiles, ITheme } from '@/components/ReactPlayground/shared.ts'
import FitFullscreen from '@/components/common/FitFullscreen.tsx'
import FlexBox from '@/components/common/FlexBox.tsx'
import _ from 'lodash'
interface CodeEditorProps {
theme: ITheme
files: IFiles
readonly?: boolean
readonlyFiles?: string[]
selectedFileName: string
options?: IEditorOptions
onSelectedFileChange?: (fileName: string) => void
onRemoveFile?: (fileName: string, files: IFiles) => void
}
const CodeEditor: React.FC<CodeEditorProps> = ({
theme,
files,
readonly,
readonlyFiles,
options,
onSelectedFileChange,
onRemoveFile,
...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)
}
return (
<>
<FitFullscreen>
<FlexBox style={{ height: '100%' }}>
<FileSelector
files={files}
readonly={readonly}
readonlyFiles={readonlyFiles}
selectedFileName={
onSelectedFileChange ? props.selectedFileName : selectedFileName
}
onChange={handleOnChangeSelectedFile}
onRemoveFile={handleOnRemoveFile}
/>
<Editor
theme={theme}
selectedFileName={selectedFileName}
files={files}
options={options}
/>
</FlexBox>
</FitFullscreen>
</>
)
}
export default CodeEditor

View File

@@ -1,10 +1,12 @@
import React from 'react' import React from 'react'
import { editor } from 'monaco-editor' import { editor } from 'monaco-editor'
export type ILanguage = 'javascript' | 'typescript' | 'json' | 'css'
export interface IFile { export interface IFile {
name: string name: string
value: string value: string
language: 'javascript' | 'typescript' | 'json' | 'css' language: ILanguage
active?: boolean active?: boolean
hidden?: boolean hidden?: boolean
} }

View File

@@ -1,5 +1,11 @@
import { strFromU8, strToU8, unzlibSync, zlibSync } from 'fflate' import { strFromU8, strToU8, unzlibSync, zlibSync } from 'fflate'
import { ICustomFiles, IFiles, IImportMap, ITheme } from '@/components/ReactPlayground/shared' import {
ICustomFiles,
IFiles,
IImportMap,
ILanguage,
ITheme
} from '@/components/ReactPlayground/shared'
import { IMPORT_MAP_FILE_NAME, reactTemplateFiles } from '@/components/ReactPlayground/files' import { IMPORT_MAP_FILE_NAME, reactTemplateFiles } from '@/components/ReactPlayground/files'
export const strToBase64 = (str: string) => { export const strToBase64 = (str: string) => {
@@ -31,8 +37,8 @@ export const setPlaygroundTheme = (theme: ITheme) => {
?.forEach((item) => item.setAttribute('class', theme)) ?.forEach((item) => item.setAttribute('class', theme))
} }
export const getPlaygroundTheme = () => { export const getPlaygroundTheme = (): ITheme => {
const isDarkTheme = JSON.parse(localStorage.getItem(STORAGE_DARK_THEME) || 'false') const isDarkTheme = JSON.parse(localStorage.getItem(STORAGE_DARK_THEME) || 'false') as ITheme
return isDarkTheme ? 'vs-dark' : 'light' return isDarkTheme ? 'vs-dark' : 'light'
} }
@@ -96,7 +102,7 @@ export const getFilesFromUrl = () => {
try { try {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const hash = window.location.hash 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) { } catch (error) {
console.error(error) console.error(error)
@@ -104,7 +110,7 @@ export const getFilesFromUrl = () => {
return files return files
} }
export const fileNameToLanguage = (name: string) => { export const fileNameToLanguage = (name: string): ILanguage => {
const suffix = name.split('.').pop() || '' const suffix = name.split('.').pop() || ''
if (['js', 'jsx'].includes(suffix)) return 'javascript' if (['js', 'jsx'].includes(suffix)) return 'javascript'
if (['ts', 'tsx'].includes(suffix)) return 'typescript' if (['ts', 'tsx'].includes(suffix)) return 'typescript'

View File

@@ -1,16 +1,29 @@
import React from 'react' import React, { useState } from 'react'
import Editor from '@/components/ReactPlayground/CodeEditor/Editor' import CodeEditor from '@/components/ReactPlayground/CodeEditor'
import { fileNameToLanguage } from '@/components/ReactPlayground/utils.ts'
import { IFiles } from '@/components/ReactPlayground/shared.ts'
const OnlineEditor: React.FC = () => { const OnlineEditor: React.FC = () => {
const [files, setFiles] = useState<IFiles>({
abc: {
name: 'App.tsx',
language: fileNameToLanguage('App.tsx'),
value: 'const a = () => {}'
},
cde: {
name: 'App.css',
language: fileNameToLanguage('App.css'),
value: '.title {}'
}
})
return ( return (
<> <>
<Editor <CodeEditor
theme={'light'} theme={'light'}
file={{ files={files}
name: 'App.tsx', selectedFileName={'abc'}
language: 'typescript', onRemoveFile={(_, files) => setFiles(files)}
value: 'const a = () => {}'
}}
/> />
</> </>
) )