该文章是学习react小册的React Playground项目的总结
当点击FileNameList组件中文件名称时,Editor组件会显示对应文件内容,Preview组件会渲染对应内容
添加zustand
如何可以让多个组件共享一份数据?
可以使用context、redux等
我这边使用Zustand
安装zustand
npm install --save zustand
创建store
使用create创建store,其中包括文件对象跟增删改查的一些方法
import { create } from 'zustand';
import { EditorFile } from '../components/CodeEditor/Editor';
import { fileName2Language } from '../utils';
export interface Files {
[key: string]: EditorFile;
}
export interface PlaygroundContext {
// 文件对象
files: {};
// 当前选择的文件
selectedFileName: string;
// 修改当前选择的文件
setSelectedFileName: (fileName: string) => void;
// 修改文件对象
setFiles: (files: Files) => void;
// 添加文件
addFile: (fileName: string) => void;
// 删除某个文件
removeFile: (fileName: string) => void;
// 修改文件名称
updateFileName: (oldFieldName: string, newFieldName: string) => void;
}
const useStore = create<PlaygroundContext>((set, get) => ({
files: initFiles,
selectedFileName: 'App.tsx',
setSelectedFileName: (fileName) => set({ selectedFileName: fileName }),
setFiles: (files) => set({ files }),
addFile: (name) => {
const files = get().files;
files[name] = {
name,
language: fileName2Language(name),
value: '',
};
return set({ files });
},
removeFile: (name) => {
const files = get().files;
delete files[name];
return set({ files });
},
updateFileName: (oldFieldName: string, newFieldName: string) => {
const files = get().files;
if (
!files[oldFieldName] ||
newFieldName === undefined ||
newFieldName === null
)
return;
const { [oldFieldName]: value, ...rest } = files;
const newFile = {
[newFieldName]: {
...value,
language: fileName2Language(newFieldName),
name: newFieldName,
},
};
return set({ files: newFile });
},
}));
export default useStore;
再提供一个可以根据不同的后缀名返回 language,用于不同语法的高亮
// 根据不同的后缀名返回 language,用于不同语法的高亮
export const fileName2Language = (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';
};
为了后续封装一个FileNameItem组件
添加了actived类名,在选中时将字体设置成cyan-400
import classnames from 'classnames';
import React, { useState, useRef, useEffect } from 'react';
export interface FileNameItemProps {
value: string;
actived: boolean;
onClick: () => void;
}
export const FileNameItem: React.FC<FileNameItemProps> = (props) => {
const { value, actived = false, onClick } = props;
const [name, setName] = useState(value);
return (
<div
className={classnames(
`inline-flex pt-2 pb-2.5 px-1.5 cursor-pointer text-sm items-center border-b-4 border-solid ${
actived ? 'border-cyan-400' : 'border-transparent'
}`,
actived ? 'text-cyan-400' : null
)}
onClick={onClick}
>
<span>{name}</span>
</div>
);
};
在FileNameList组件引入FileNameItem、useStore
import { FC, useEffect, useState } from 'react';
import useStore from '@/store';
import { FileNameItem } from './FileNameItem';
const FileNameList: FC = () => {
const files = useStore(state => state.files);
const selectedFileName = useStore(state => state.selectedFileName);
const setSelectedFileName = useStore(state => state.setSelectedFileName);
const [tabs, setTabs] = useState(['']);
useEffect(() => {
setTabs(Object.keys(files));
}, [files]);
return (
<div className="flex items-center h-12 overflow-x-auto overflow-y-hidden border-b border-solid border-gray-400 box-border text-black bg-white scroll-bar">
{tabs.map((item, index) => (
<FileNameItem
key={item + index}
value={item}
actived={selectedFileName === item}
onClick={() => setSelectedFileName(item)}
></FileNameItem>
))}
</div>
);
};
export default FileNameList;
渲染文件名称列表
Vue SFC Playground会有几个初始化文件,我们也添加一下
新建files文件
import importMap from './template/import-map.json?raw';
import AppCss from './template/App.css?raw';
import App from './template/App.tsx?raw';
import main from './template/main.tsx?raw';
import { fileName2Language } from './utils';
import { Files } from './store';
// app 文件名
export const APP_COMPONENT_FILE_NAME = 'App.tsx';
// esm 模块映射文件名
export const IMPORT_MAP_FILE_NAME = 'import-map.json';
// app 入口文件名
export const ENTRY_FILE_NAME = 'main.tsx';
export const initFiles: Files = {
[ENTRY_FILE_NAME]: {
name: ENTRY_FILE_NAME,
language: fileName2Language(ENTRY_FILE_NAME),
value: main,
},
[APP_COMPONENT_FILE_NAME]: {
name: APP_COMPONENT_FILE_NAME,
language: fileName2Language(APP_COMPONENT_FILE_NAME),
value: App,
},
'App.css': {
name: 'App.css',
language: 'css',
value: AppCss,
},
[IMPORT_MAP_FILE_NAME]: {
name: IMPORT_MAP_FILE_NAME,
language: fileName2Language(IMPORT_MAP_FILE_NAME),
value: importMap,
},
};
在useStore中引入
+ import { initFiles } from '../files';
const useStore = create<PlaygroundContext>((set, get) => ({
+ files: initFiles,
}));
显示正常
原生的滚动条不美观,优化一下
在App.css利用tailwind的@layer,定义好滚动条样式
+@layer components {
+ .scroll-bar {
+ &::-webkit-scrollbar {
+ height: 1px;
+ }
+
+ &::-webkit-scrollbar-track {
+ @apply bg-neutral-50;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ @apply bg-cyan-400;
+ }
+
+ }
+}
在FileNameList组件添加定义好滚动条的类名scroll-bar
return (
- <div className="flex items-center h-12 overflow-x-auto overflow-y-hidden border-b border-solid border-gray-400 box-border text-black bg-white ">
+ <div className="flex items-center h-12 overflow-x-auto overflow-y-hidden border-b border-solid border-gray-400 box-border text-black bg-white scroll-bar">
{tabs.map((item, index) => (
<FileNameItem
key={item + index}
value={item}
actived={selectedFileName === item}
onClick={() => setSelectedFileName(item)}
></FileNameItem>
))}
</div>
);
现在美观很多
添加横向滚动事件
e.deltaY表示鼠标滚轮在垂直方向上的滚动距离。正值表示向下滚动,负值表示向上滚动。由于我们想要实现横向滚动,所以这里直接使用e.deltaY来改变scrollLeft的值
+ const containerRef = useRef<HTMLDivElement>(null);
+
+ const handleWheel = (e: WheelEvent) => {
+ containerRef.current!.scrollLeft += e.deltaY;
+ }
最外层添加overflow-hidden,为了横向滚动防止会带动外层
function App() {
return (
+ <div className='h-screen overflow-hidden'>
<Header />
<Allotment defaultSizes={[100, 100]}>
<Allotment.Pane minSize={0}>
<CodeEditor />
</Allotment.Pane>
<Allotment.Pane minSize={0}>
<Preview />
</Allotment.Pane>
</Allotment>
</div>
);
}
添加后滚动不会带动外层
添加拖拽
安装react-dnd相关的包
npm install react-dnd react-dnd-html5-backend
main.tsx添加dnd的DndProvider,用于在不同组件之间共享数据
+import { DndProvider } from 'react-dnd';
+import { HTML5Backend } from 'react-dnd-html5-backend';
+createRoot(document.getElementById('root')!).render(<DndProvider backend={HTML5Backend}><App></App></DndProvider>);
在FileNameItem组件设置useDrag、useDrop,在isOver时改变背景色,这样拖动更突出一点,在hover时调用swapIndex回传新旧索引值
const ref = useRef(null)
const [, drag] = useDrag({
type: "FileNameItem",
item: {
index
},
})
const [{ isOver }, drop] = useDrop(({
accept: "FileNameItem",
hover(item: any) {
swapIndex(item.index, index)
item.index = index
},
collect(monitor) {
return {
isOver: monitor.isOver()
}
}
})
)
useEffect(() => {
drag(ref)
drop(ref)
}, [])
<div
+ ref={ref}
className={classnames(
`inline-flex pt-2 pb-2.5 px-1.5 cursor-pointer text-sm items-center border-b-4 border-solid ${actived ? 'border-cyan-400' : 'border-transparent'
+ } ${isOver ? " bg-cyan-100" : ""}`,
)}
onClick={onClick}
>
<span>{name}</span>
</div>
因为拖动是要修改tabs的数据,在FileNameList组件定义swapIndex函数,回传新旧索引值来改变未知
const swapIndex = useCallback((index1: number, index2: number) => {
const tmp = tabs[index1];
tabs[index1] = tabs[index2];
tabs[index2] = tmp;
setTabs([...tabs]);
}, [tabs])
<FileNameItem
key={item + index}
value={item}
actived={selectedFileName === item}
onClick={() => setSelectedFileName(item)}
+ index={index}
+ swapIndex={swapIndex}
></FileNameItem>
添加同步修改
修改内容再切换文件,不会保存
在CodeEditor组件引入useStore,添加一个onEditorChange函数在修改文件内容时,将同步保存到files中
const files = useStore(state => state.files);
const selectedFileName = useStore(state => state.selectedFileName);
const setFiles = useStore(state => state.setFiles);
const file = files[selectedFileName];
const onEditorChange: OnChange = (value?: string) => {
files[file.name].value = value!
setFiles({ ...files })
}
- <Editor file={file} />
+ <Editor file={file} onChange={onEditorChange} />