From 57098156136294ccea6fc8798cf0d2a1af90d123 Mon Sep 17 00:00:00 2001 From: FatttSnake Date: Mon, 8 Jan 2024 18:09:05 +0800 Subject: [PATCH] Add FileSelector to CodeEditor --- src/assets/svg/close.svg | 1 + .../ReactPlayground/CodeEditor/Editor/ata.ts | 37 +++-- .../CodeEditor/Editor/hooks.ts | 41 ++++- .../CodeEditor/Editor/index.tsx | 23 ++- .../CodeEditor/Editor/monacoConfig.ts | 60 +++---- .../CodeEditor/FileSelector/Item.tsx | 114 +++++++++++++ .../FileSelector/file-selector.scss | 54 +++++++ .../CodeEditor/FileSelector/index.tsx | 151 ++++++++++++++++++ .../ReactPlayground/CodeEditor/index.tsx | 72 +++++++++ src/components/ReactPlayground/shared.ts | 4 +- src/components/ReactPlayground/utils.ts | 16 +- src/pages/OnlineEditor.tsx | 29 +++- 12 files changed, 540 insertions(+), 62 deletions(-) create mode 100644 src/assets/svg/close.svg create mode 100644 src/components/ReactPlayground/CodeEditor/FileSelector/Item.tsx create mode 100644 src/components/ReactPlayground/CodeEditor/FileSelector/file-selector.scss create mode 100644 src/components/ReactPlayground/CodeEditor/FileSelector/index.tsx create mode 100644 src/components/ReactPlayground/CodeEditor/index.tsx diff --git a/src/assets/svg/close.svg b/src/assets/svg/close.svg new file mode 100644 index 0000000..3e959cf --- /dev/null +++ b/src/assets/svg/close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/ReactPlayground/CodeEditor/Editor/ata.ts b/src/components/ReactPlayground/CodeEditor/Editor/ata.ts index 370e8c9..df03b68 100644 --- a/src/components/ReactPlayground/CodeEditor/Editor/ata.ts +++ b/src/components/ReactPlayground/CodeEditor/Editor/ata.ts @@ -18,32 +18,47 @@ const delegateListener = createDelegate() type InferSet = T extends Set ? U : never -export const createATA = async () => { - // @ts-ignore +export interface TypeHelper { + dispose: () => void + acquireType: (code: string) => void + removeListener: ( + event: T, + handler: InferSet + ) => void + addListener: ( + event: T, + handler: InferSet + ) => void +} + +export const createATA = async (): Promise => { + // 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) => { - let result: any try { - result = fetch(input, init) + return fetch(input, init) } catch (error) { console.error('Error fetching data:', error) } - return result + return new Promise(() => {}) }, delegate: { receivedFile: (code, path) => { delegateListener.receivedFile.forEach((fn) => fn(code, path)) }, - progress: (downloaded, estimatedTotal) => { - delegateListener.progress.forEach((fn) => fn(downloaded, estimatedTotal)) - }, started: () => { delegateListener.started.forEach((fn) => fn()) }, + progress: (downloaded, estimatedTotal) => { + delegateListener.progress.forEach((fn) => fn(downloaded, estimatedTotal)) + }, finished: (files) => { delegateListener.finished.forEach((fn) => fn(files)) } @@ -56,7 +71,8 @@ export const createATA = async () => { event: T, handler: InferSet ) => { - // @ts-ignore + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error delegateListener[event].add(handler) } @@ -64,7 +80,8 @@ export const createATA = async () => { event: T, handler: InferSet ) => { - // @ts-ignore + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error delegateListener[event].delete(handler) } diff --git a/src/components/ReactPlayground/CodeEditor/Editor/hooks.ts b/src/components/ReactPlayground/CodeEditor/Editor/hooks.ts index 2834852..f14aa3b 100644 --- a/src/components/ReactPlayground/CodeEditor/Editor/hooks.ts +++ b/src/components/ReactPlayground/CodeEditor/Editor/hooks.ts @@ -2,7 +2,7 @@ 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 } from '@/components/ReactPlayground/CodeEditor/Editor/ata.ts' +import { createATA, TypeHelper } from '@/components/ReactPlayground/CodeEditor/Editor/ata.ts' export const useEditor = () => { const doOpenEditor = ( @@ -44,7 +44,7 @@ export const useEditor = () => { editor: editor.IStandaloneCodeEditor, monaco: Monaco, defaultValue: string, - onWatch: any + onWatch: (typeHelper: TypeHelper) => () => void ) => { const typeHelper = await createATA() @@ -70,3 +70,40 @@ export const useEditor = () => { 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 + } +} diff --git a/src/components/ReactPlayground/CodeEditor/Editor/index.tsx b/src/components/ReactPlayground/CodeEditor/Editor/index.tsx index e3d1610..7be12ca 100644 --- a/src/components/ReactPlayground/CodeEditor/Editor/index.tsx +++ b/src/components/ReactPlayground/CodeEditor/Editor/index.tsx @@ -2,23 +2,30 @@ import React from 'react' import { editor, Selection } from 'monaco-editor' import MonacoEditor, { Monaco } from '@monaco-editor/react' 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 { fileNameToLanguage } from '@/components/ReactPlayground/utils' -import { useEditor } from '@/components/ReactPlayground/CodeEditor/Editor/hooks' +import { useEditor, useTypesProgress } from '@/components/ReactPlayground/CodeEditor/Editor/hooks' interface EditorProps { - file: IFile + files?: IFiles + selectedFileName?: string onChange?: (code: string | undefined) => void options?: IEditorOptions theme?: ITheme - files?: IFiles onJumpFile?: (fileName: string) => void } -const Editor: React.FC = ({ file, files, theme, onChange, options, onJumpFile }) => { +const Editor: React.FC = ({ + files = {}, + selectedFileName = '', + theme, + onChange, + options, + onJumpFile +}) => { const editorRef = useRef() - const { doOpenEditor, loadJsxSyntaxHighlight } = useEditor() + const { doOpenEditor, loadJsxSyntaxHighlight, autoLoadExtraLib } = useEditor() const jsxSyntaxHighlightRef = useRef<{ highlighter: (code?: string | undefined) => void dispose: () => void @@ -26,6 +33,8 @@ const Editor: React.FC = ({ file, files, theme, onChange, options, highlighter: () => undefined, dispose: () => undefined }) + const { onWatch } = useTypesProgress() + const file = files[selectedFileName] ?? { name: 'Untitled' } const handleOnEditorDidMount = (editor: editor.IStandaloneCodeEditor, monaco: Monaco) => { editorRef.current = editor @@ -64,6 +73,8 @@ const Editor: React.FC = ({ file, files, theme, onChange, options, } jsxSyntaxHighlightRef.current = loadJsxSyntaxHighlight(editor, monaco) + + void autoLoadExtraLib(editor, monaco, file.value, onWatch) } useEffect(() => { diff --git a/src/components/ReactPlayground/CodeEditor/Editor/monacoConfig.ts b/src/components/ReactPlayground/CodeEditor/Editor/monacoConfig.ts index 20f570a..7411eb7 100644 --- a/src/components/ReactPlayground/CodeEditor/Editor/monacoConfig.ts +++ b/src/components/ReactPlayground/CodeEditor/Editor/monacoConfig.ts @@ -1,34 +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', // 隐藏控制行号 + 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', // 隐藏控制行号 } diff --git a/src/components/ReactPlayground/CodeEditor/FileSelector/Item.tsx b/src/components/ReactPlayground/CodeEditor/FileSelector/Item.tsx new file mode 100644 index 0000000..27bbc02 --- /dev/null +++ b/src/components/ReactPlayground/CodeEditor/FileSelector/Item.tsx @@ -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 = ({ + readonly = false, + value, + active = false, + onOk, + onCancel, + onRemove, + onClick, + onValidate, + ...prop +}) => { + const inputRef = useRef(null) + const [fileName, setFileName] = useState(value) + const [creating, setCreating] = useState(prop.creating) + + const handleKeyDown = (event: React.KeyboardEvent) => { + 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) => { + setFileName(e.target.value) + } + + const handleOnDelete = (e: React.MouseEvent) => { + e.stopPropagation() + if (confirm(`确定删除文件 ${value} ?`)) { + onRemove?.(value) + } + } + + useEffect(() => { + inputRef.current?.focus() + }, []) + + return ( +
+ {creating ? ( +
+ + {fileName} +
+ ) : ( + <> +
{value}
+ {!readonly && ( +
+ +
+ )} + + )} +
+ ) +} + +export default Item diff --git a/src/components/ReactPlayground/CodeEditor/FileSelector/file-selector.scss b/src/components/ReactPlayground/CodeEditor/FileSelector/file-selector.scss new file mode 100644 index 0000000..9da47bf --- /dev/null +++ b/src/components/ReactPlayground/CodeEditor/FileSelector/file-selector.scss @@ -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; + } + } +} \ No newline at end of file diff --git a/src/components/ReactPlayground/CodeEditor/FileSelector/index.tsx b/src/components/ReactPlayground/CodeEditor/FileSelector/index.tsx new file mode 100644 index 0000000..6e0039a --- /dev/null +++ b/src/components/ReactPlayground/CodeEditor/FileSelector/index.tsx @@ -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 = ({ + files = {}, + onChange, + onError, + readonly = false, + readonlyFiles = [], + onRemoveFile, + onAddFile, + onUpdateFileName, + selectedFileName = '' +}) => { + const [tabs, setTabs] = useState([]) + 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 ( + <> +
+ {tabs.map((item, index) => ( + handleOnSaveTab(name, item)} + onCancel={handleOnCancel} + onRemove={handleOnRemove} + onClick={() => handleOnClickTab(item)} + /> + ))} +
+ + ) +} + +export default FileSelector diff --git a/src/components/ReactPlayground/CodeEditor/index.tsx b/src/components/ReactPlayground/CodeEditor/index.tsx new file mode 100644 index 0000000..0952284 --- /dev/null +++ b/src/components/ReactPlayground/CodeEditor/index.tsx @@ -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 = ({ + 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 ( + <> + + + + + + + + ) +} + +export default CodeEditor diff --git a/src/components/ReactPlayground/shared.ts b/src/components/ReactPlayground/shared.ts index bbdf821..02fee27 100644 --- a/src/components/ReactPlayground/shared.ts +++ b/src/components/ReactPlayground/shared.ts @@ -1,10 +1,12 @@ import React from 'react' import { editor } from 'monaco-editor' +export type ILanguage = 'javascript' | 'typescript' | 'json' | 'css' + export interface IFile { name: string value: string - language: 'javascript' | 'typescript' | 'json' | 'css' + language: ILanguage active?: boolean hidden?: boolean } diff --git a/src/components/ReactPlayground/utils.ts b/src/components/ReactPlayground/utils.ts index 0569a37..95b5a90 100644 --- a/src/components/ReactPlayground/utils.ts +++ b/src/components/ReactPlayground/utils.ts @@ -1,5 +1,11 @@ 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' export const strToBase64 = (str: string) => { @@ -31,8 +37,8 @@ export const setPlaygroundTheme = (theme: ITheme) => { ?.forEach((item) => item.setAttribute('class', theme)) } -export const getPlaygroundTheme = () => { - const isDarkTheme = JSON.parse(localStorage.getItem(STORAGE_DARK_THEME) || 'false') +export const getPlaygroundTheme = (): ITheme => { + const isDarkTheme = JSON.parse(localStorage.getItem(STORAGE_DARK_THEME) || 'false') as ITheme return isDarkTheme ? 'vs-dark' : 'light' } @@ -96,7 +102,7 @@ export const getFilesFromUrl = () => { 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) @@ -104,7 +110,7 @@ export const getFilesFromUrl = () => { return files } -export const fileNameToLanguage = (name: string) => { +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' diff --git a/src/pages/OnlineEditor.tsx b/src/pages/OnlineEditor.tsx index 04293c8..974ce4c 100644 --- a/src/pages/OnlineEditor.tsx +++ b/src/pages/OnlineEditor.tsx @@ -1,16 +1,29 @@ -import React from 'react' -import Editor from '@/components/ReactPlayground/CodeEditor/Editor' +import React, { useState } from 'react' +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 [files, setFiles] = useState({ + abc: { + name: 'App.tsx', + language: fileNameToLanguage('App.tsx'), + value: 'const a = () => {}' + }, + cde: { + name: 'App.css', + language: fileNameToLanguage('App.css'), + value: '.title {}' + } + }) + return ( <> - {}' - }} + files={files} + selectedFileName={'abc'} + onRemoveFile={(_, files) => setFiles(files)} /> )