一 功能
- 可添加时间、日期、星期、字幕、图片、视频和背景音乐。
- 可修改布局大小。画布及元素的个别属性(如x,y,width,height,fontsize)将会通过一定比例进行缩放,以此达到接近实际所看到的效果。
- 可通过拖拽修改元素位置、添加新元素;可对元素进行收缩以改变其尺寸等属性。
- 支持修改时间、日期、星期的颜色、大小;支持修改字幕的颜色、大小、滚动方向、滚动速度;支持对图片元素/视频元素添加多个文件,根据不同的类型,文件列表会过滤出对应的文件类型。
二 效果
可在这里直接操作:stackblitz-starters-vn7gmq.stackblitz.io/
(如果下面动图看不清,可以点击放大)
三 结构介绍
3.1 第三方库
reactjs-video-playlist-player@1.1.1
:实现让多个视频文件连续播放react-grid-layout-next@2.2.0
:让元素在画布中布局与缩放 这是基于react-grid-layout
写的。原本使用react-grid-layout
遇到了各种问题诸如onLayoutChange会调用两次、allowOverlap为ture时onLayoutChange不生效、拖拽点击不能同时使用等问题,总而言之言而总之最后用了这个库ahooks@3.7.8
:用到了里面的useSize
(监听画布变化)、useDrag
、useDrop
(实现从外部拖拽元素进画布)。react-fast-marquee
:实现字幕滚动
3.2 视图结构
MediaLayout
jsx
<Row style={{ minHeight: '600px' }}>
<Col span={20}>
<Row>
<BaseInfo />
</Row>
<Row style={{ height: 'calc(100% - 45px)' }}>
<Col span={6} style={{ height: '100%' }}>
<EleList />
</Col>
<Col span={18}>
<Row style={{ height: '64px' }}>
<EleSource />
</Row>
<Row style={{ height: 'calc(100% - 64px)' }}>
<EleCanvas/>
</Row>
</Col>
</Row>
</Col>
<Col span={4}>
<ElePropPanel/>
</Col>
</Row>
3.3 数据结构
原始数据
ts
{
program_name: '节目名称',
program_width: 1920,
program_height: 1080,
eles: [], // 元素列表。里面的元素属性不一定与画布中的元素属性一致。
// 1. 元素列表的属性是原始数据,画布中布局相关的属性是经过比率转换过的数据
// 2. 这里元素属性的数量不一定与画布中元素属性相同。
// 比如对于字幕元素,实际情况是只有fontsize,但在画布中我们还需要宽高来做视觉效果,
// 这个宽高是从fontsize计算而来的,是画布元素中多出来的属性
}
元素对象
ts
// 基本信息
type TBaseInfo = {
program_name: string;
program_width: number;
program_height: number;
};
// 元素类型
type TEleType =
| 'image'
| 'video'
| 'date'
| 'time'
| 'week'
| 'caption'
| 'audio';
// 1. 通用属性:
// 必须存在的属性
type TBaseEle = {
type: TEleType;
uuid: string;
};
// 画布相关属性
type TLayoutProps = {
x: number;
y: number;
width: number;
height: number;
};
// 1. 具体属性:
// 图片元素、视频元素:包含files文件列表
type TMediaEle = TBaseEle &
TLayoutProps & {
files: Array<TFile>;
};
// 日期、时间、星期:包含字体颜色、字体大小
type TTxtEle = TBaseEle &
TLayoutProps & {
color: string;
fontSize: number;
};
// 字幕元素:包含字体颜色、字体大小、滚动方向、滚动速度、文本内容
type TCaptionEle = TTxtEle & {
direction: EDirection;
speed: number;
content: string;
};
// 背景音乐:不包含布局相关属性,但会多一个files文件列表
type TAudioEle = TBaseEle & {
files: Array<TFile>;
};
// 元素
type TEle = TMediaEle | TTxtEle | TCaptionEle | TAudioEle;
// 元素列表
type TEleList = Array<TEle>;
文件元素
ts
type TFile = {
id: string;
type:'image'|'audio'|'video';
path: string;
};
type TFileList = Array<TFile>;
3.4 原数据宽高与画布宽高
一般情况下,原数据的宽高画布,比如1920 x 1080,是很难直接用到电脑屏幕上的。这里是通过同比例缩小来解决这个问题的。 首先,我们的视图有一个组件EleCanvas容器,里面放处理后的画布(等比例缩小后的画布)。下面是一个计算过程:
ts
// useRatioSize.ts
import { FloatFormater } from '../utils'; // 对浮点数做处理,传入浮点数与需要保留的小数位数
type TSize = [number, number];
const useRatioSize = (
windowSize: TSize, // 原数据宽高
settingSize: TSize // 画布容器宽高
): [number, number, TSize] => {
const [wWidth, wHeight] = windowSize;
const [sWidth, sHeight] = settingSize;
if (sWidth === 0 || sHeight === 0 || wWidth === 0 || wHeight === 0) {
return [1, 1, [0, 0]];
}
// 如果窗口的长和高都比设置的长和高大,那么使用的就是实际宽高
// 否则要计算缩小比率。这里需要向上取整,否则即便 设置宽高 比 窗口宽高 大1.几倍,还是会当做1来看,这样就起不到同比例缩小效果
const widthRatio =
wWidth >= sWidth ? 1 : FloatFormater(Math.ceil(sWidth / wWidth), 0);
const heightRatio =
wHeight >= sHeight ? 1 : FloatFormater(Math.ceil(sHeight / wHeight), 0);
// 且还要返回 设置宽高经过同比例缩小后,能放在画布容器中的虚拟宽高(所视宽高)
const viewSize = [
FloatFormater(sWidth / widthRatio, 0),
FloatFormater(sHeight / heightRatio, 0),
] as TSize;
return [widthRatio, heightRatio, viewSize];
};
export default useRatioSize;
3.5 通信与联动
这里以兄弟间通信为主,采用了上下文的方式。
- 捋一捋整个过程中组件间会相互用到的状态,并创建上下文:
createContext
。
ts
import { createContext } from 'react';
type LayoutProviderValue = {
selectedEleKey: string; // 选中元素的uuid
setSelectedEleKey: any; // 修改 选中元素的uuid
eleList: TEleList; // 元素列表
setEleList: any; // 修改 元素列表
fileList: Array<any>; // 文件列表(源)
baseInfo: TBaseInfo; // 节目基本信息
setBaseInfo: any; // 修改 节目基本信息
widthRatio: number; // 节目宽 对 画布宽的比率
heightRatio: number;// 节目高 对 画布高的比率
viewSize: TSize;// 画布宽高
};
const LayoutContext = createContext<LayoutProviderValue | undefined>(undefined);
export default LayoutContext;
其中下面几个使用最为频繁:
selectedEleKey
:选中元素的key。需要在元素列表(EleList)、元素画布(EleCanvas)、元素属性(ElePropPanel)用到
eleList
:元素列表。需要在元素列表(EleList)、元素画布(EleCanvas)用到
widthRatio
:节目宽对于画布宽的比率。转换元素属性值时用到
heightRatio
:节目高对于画布高的比率。转换元素属性值时用到
- 将数据与修改数据的方法提供给各个组件:
LayoutContext.Provider
ts
// 初始数据
const initData={
program_name: '节目名称',
program_width: 1920,
program_height: 1080,
eles: [
{
type: 'audio',
files: [],
},
],
}
// 文件数据
const fileList=[
{
id: '1',
type: 'image',
path: 'files/111.jpg',
},
]
const MediaLayout = () => {
const canvasRef = useRef<HTMLDivElement | null>(null);// 通过ref传给EleCanvas,需要拿到它的狂傲
const size = useSize(canvasRef);
const [selectedEleKey, setSelectedEleKey] = useState('');
// 基本数据
const [baseInfo, setBaseInfo] = useState<TBaseInfo>({
program_name: initData.program_name,
program_width: initData.program_width,
program_height: initData.program_height,
});
// 元素列表
const [eleList, setEleList] = useState<TEleList>(
generateEleList(test.eles, baseInfo) // 格式化数据。
// 有些数据是原始数据中没有的,比如所字幕只有一个字体大小,但这里为了能放在画布上,还要给它赋值宽高
);
// 拿到画布转化比
const [widthRatio, heightRatio, viewSize] = useRatioSize(
[size ? size.width : 0, size ? size.height : 0],
[baseInfo.program_width, baseInfo.program_height]
);
return (
<LayoutContext.Provider
value={{
selectedEleKey,
setSelectedEleKey,
eleList,
setEleList,
fileList,
baseInfo,
setBaseInfo,
widthRatio,
heightRatio,
viewSize,
}}
>
<Row style={{ minHeight: '600px' }}>
{/* ... */}
<EleCanvas ref={canvasRef} />
{/* ... */}
</Row>
</LayoutContext.Provider>
);
};
export default MediaLayout;
- 在组件内使用上下文传递过来的属性:
useContext
ts
import LayoutContext from './Context';
import { useContext } from 'react';
let { eleList,...} = useContext(LayoutContext);
其实这样很麻烦,因为每次进入一个新组件,都要导入LayoutContext
和useContext
,然后再拿到东西。于是乎我封装了钩子useContextHandler
,避免频繁引用LayoutContext
和useContext
的操作,并且还在里面扩展了一些操作:
ts
import LayoutContext from './Context';
import { useContext } from 'react';
import { deepClone } from '../utils';
import { TxtUtil, CaptionUtil } from './helper';
const useContextHandler = () => {
let {
eleList,
selectedEleKey,
setSelectedEleKey,
setEleList,
fileList,
setBaseInfo,
baseInfo,
widthRatio,
heightRatio,
viewSize,
} = useContext(LayoutContext);
// 扩展:当前选中元素
const eleInfoIdx = eleList.findIndex((item) => item.uuid === selectedEleKey);
const eleInfo = eleList[eleInfoIdx];
// 扩展:表单中修改基础信息 (节目名称,节目宽度,节目高度)
const handleBaseInputChange = (e: Event) => {
const { name, value } = e.target as HTMLInputElement;
handleBaseSelectChange(name, value);
};
const handleBaseSelectChange = (name: string, value: any) => {
setBaseInfo({
...baseInfo,
[name]: value,
});
};
// 扩展:表单中修改元素属性
const handleEleInputChange = (e: Event) => {
const { name, value } = e.target as HTMLInputElement;
handleEleSelectChange(name, value);
};
const handleEleSelectChange = (name: string, value: any) => {
// 对于复杂的数据,就比如现在的eleList
// 修改里面的元素属性时,是不能直接在原来地址上修改的
// 因为根据原理,状态只会对比第一层,再深层的是检测不到的,
// 所以如果地址没有变更,只是在原地址修改,渲染时将监测不到变化,从而不会重新渲染。
// 因此这里需要对修改的对象进行一次深克隆,来重置它的地址
let newEleInfo = deepClone(eleInfo);
newEleInfo[name] = value;
// 联动关系:对于文本类型,如果修改了字体大小,根据字体大小定义它在画布中的宽高
switch (name) {
case 'fontSize':
if (TxtUtil.isType(eleInfo.type)) {
TxtUtil.toRect(newEleInfo);
}
if (CaptionUtil.isType(eleInfo.type)) {
CaptionUtil.toRect(newEleInfo, baseInfo.program_width);
}
break;
default:
break;
}
// 更新状态
setEleList((prev) => {
prev[eleInfoIdx] = newEleInfo;
return [...prev];
});
};
// 删除元素
const handleDelEle = (uuid: string) => {
const idx = eleList.findIndex((item) => item.uuid === uuid);
eleList.splice(idx, 1);
setEleList([...eleList]);
};
return {
eleList,
setEleList,
selectedEleKey,
setSelectedEleKey,
fileList,
baseInfo,
widthRatio,
heightRatio,
viewSize,
// 扩展的
handleEleInputChange,
handleEleSelectChange,
eleInfo, // 当前选中的元素属性
handleDelEle,
handleBaseInputChange,
handleBaseSelectChange,
};
};
export default useContextHandler;
四 组件结构
4.1 元素列表EleList
ts
// index.tsx
import EleItem from './EleItem';
import useContextHandler from '../useContextHandler';
import { useState } from 'react';
type AudioProps = {
files: Array<TFile>;
isPlaying: boolean;
};
const EleList = () => {
let { eleList, selectedEleKey } = useContextHandler();
const [audio, setAudio] = useState<AudioProps>({
files: [],
isPlaying: false,
});
const handlePlay = (item) => {...};
return (
<div className="MediaLayout-EleList">
{audio.isPlaying && (
{/* 下面的地址是我配置的apache地址,根据实际情况写 */}
<audio
src={`http://127.0.0.1:8000/${audio.files[0].path}`}
autoPlay
style={{
width: '100%',
padding: '10px',
}}
controls
/>
)}
{eleList.map((item) => (
<EleItem
item={item}
isSelected={item.uuid === selectedEleKey}
key={item.uuid}
onPlay={handlePlay.bind(null, item)}
/>
))}
</div>
);
};
export default EleList;
EleItem.tsx
tsx
import { getListItemData } from '../helper';
import useContextHandler from '../useContextHandler';
import { DeleteOutlined, PlayCircleFilled } from '@ant-design/icons';
type EleItemProps = {
item: TEle;
isSelected: boolean;
onPlay: () => void;
};
const EleItem = ({ item, isSelected, onPlay }: EleItemProps) => {
const [icon, title] = getListItemData(item); // 根据不同的元素获取对应的图标和标题
let { setSelectedEleKey, handleDelEle } = useContextHandler();
const handleDel = () => {
handleDelEle(item.uuid);
};
return (
<div
className={
isSelected
? 'MediaLayout-EleList-EleItem selected'
: 'MediaLayout-EleList-EleItem'
}
onClick={() => setSelectedEleKey(item.uuid)}
>
<span className="MediaLayout-EleList-EleItem__icon">{icon}</span>
<span className="MediaLayout-EleList-EleItem__title">{title}</span>
{/* 背景音乐不可删除 */}
{item.type !== 'audio' && (
<span
className="MediaLayout-EleList-EleItem__handler"
onClick={() => handleDel()}
>
<DeleteOutlined style={{ color: '#f5222d', fontSize: '12px' }} />
</span>
)}
{/* 当文件没有在播放时可以点击播放 */}
{item.type === 'audio' && (item as TAudioEle).files.length !== 0 && (
<span className="MediaLayout-EleList-EleItem__handler">
<PlayCircleFilled
style={{ color: '#73d13d', fontSize: '12px' }}
onClick={onPlay}
/>
</span>
)}
</div>
);
};
export default EleItem;
4.2 元素属性ElePropPanel
tsx
// index.tsx
import useContextHandler from '../useContextHandler';
import { Empty, Form } from 'antd';
import { getComponentByKey } from '../helper';
// 因为很多元素的属性时通用的,所以定义了getComponentByKey,来根据元素属性的key,去渲染对应的组件
const ElePropPanel = () => {
let { eleInfo, handleEleInputChange, handleEleSelectChange, fileList } =
useContextHandler();
if (eleInfo === undefined) {
return (
<div className="MediaLayout-ElePropPanel">
<div className="MediaLayout-ElePropPanel__Title">元素属性</div>
<div className="MediaLayout-ElePropPanel__Conetnt">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="请选择元素"
/>
</div>
</div>
);
}
return (
<div className="MediaLayout-ElePropPanel">
<div className="MediaLayout-ElePropPanel__Title">元素属性</div>
<div className="MediaLayout-ElePropPanel__Conetnt">
<Form labelCol={{ span: 8 }} wrapperCol={{ span: 16 }} size="small">
{Object.keys(eleInfo).map((key) =>
getComponentByKey({
key,
value: eleInfo[key],
handleInputChange: handleEleInputChange,
handleSelectChange: handleEleSelectChange,
fileList,// files属性中会用到,所以要传文件列表
type: eleInfo.type,
})
)}
</Form>
</div>
</div>
);
};
export default ElePropPanel;
getComponentByKey:根据属性key生成对应的表单组件
tsx
export const getComponentByKey = ({
key,
value,
fileList,
type,
handleInputChange,
handleSelectChange,
}): ReactNode => {
const style = { width: '92px' };
const baseFormItemProps = { key, label: key };
const baseProps = { value, name: key, style };
switch (key) {
// 数字类型
case 'x':
case 'y':
case 'fontSize':
case 'speed':
case 'width':
case 'height':
// 如果是字幕,不显示x
if (CaptionUtil.isType(type) && key === 'x') {
return null;
}
// 如果是普通文本或字幕,不显示宽高。因为它将由字体大小决定
if (
(key === 'width' || key === 'height') &&
(TxtUtil.isType(type) || CaptionUtil.isType(type))
) {
return null;
}
return (
<FormItem {...baseFormItemProps}>
<InputNumber
{...baseProps}
onChange={handleSelectChange.bind(null, key)}
/>
</FormItem>
);
case 'content':
return (
<FormItem {...baseFormItemProps}>
<Input.TextArea
{...baseProps}
showCount
maxLength={100}
style={{ height: 120 }}
onChange={handleInputChange}
/>
</FormItem>
);
case 'direction':
return (
<FormItem {...baseFormItemProps}>
<Select
{...baseProps}
onChange={handleSelectChange.bind(null, 'direction')}
options={[
{ value: 0, label: '静止' },
{ value: 1, label: '向左滚动' },
{ value: 2, label: '向右滚动' },
]}
/>
</FormItem>
);
case 'color':
return (
<FormItem {...baseFormItemProps}>
<ColorPicker
style={style}
showText
value={value as Color}
onChange={(_, hex) => {
handleSelectChange('color', hex);
}}
/>
</FormItem>
);
case 'files':
// 这个稍微复杂写,拎出来写:files表示当前勾选的文件;source表示文件源
return <FilesBox files={value} source={fileList} type={type} />;
default:
return null;
}
};
FilesBox.tsx:文件选择器
tsx
import { Radio, Checkbox } from 'antd';
import { useState } from 'react';
import { Empty } from 'antd';
import type { CheckboxValueType } from 'antd/es/checkbox/Group';
import { getFileName } from '../../utils'; // 当前得知的只有路径path,需要从path中取到文件名用于展示
import useContextHandler from '../useContextHandler';
type FilesBoxProps = {
files: Array<TFile>;
source: Array<any>;
type: TEleType;
};
const FilesBox = ({ files, source, type }: FilesBoxProps) => {
const [selected, setSelected] = useState('1');
const fileKeys = files.map((file) => file.id); // 当前勾选的文件key
let { handleEleSelectChange } = useContextHandler();
const realSource = source.filter((item) => item.type === type); // 根据类型过滤对应的文件源(图片/视频/音频)
// 勾选到key后,重新在文件源中找到文件对象,因为files保存的是一个文件对象数组
const onChangeCheck = (values: CheckboxValueType[]) => {
let files = [];
realSource.forEach((file) => {
if (values.includes(file.id)) {
files.push(file);
}
});
handleEleSelectChange('files', files);
};
return (
<div className="MediaLayout-ElePropPanel-FilesBox">
<div className="MediaLayout-ElePropPanel-FilesBox__Header">
<Radio.Group
value={selected}
style={{ width: '100%' }}
onChange={(e) => {
setSelected(e.target.value);
}}
>
<Radio.Button value="1" key="1">
文件列表
</Radio.Button>
<Radio.Button value="2" key="2">
选择文件
</Radio.Button>
</Radio.Group>
</div>
{selected === '1' ? (
<div className="MediaLayout-ElePropPanel-FilesBox__List">
{files.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="请选择文件"
/>
) : (
files.map((item) => <li key={item.id}>{getFileName(item.path)}</li>)
)}
</div>
) : (
<div className="MediaLayout-ElePropPanel-FilesBox__List">
<Checkbox.Group onChange={onChangeCheck} value={fileKeys}>
{realSource.map((item) => (
<li>
<Checkbox value={item.id} key={item.id}>
{getFileName(item.path)}
</Checkbox>
</li>
))}
</Checkbox.Group>
</div>
)}
</div>
);
};
export default FilesBox;
4.3 元素拖拽源EleSource
以上图标都是从iconfont找的相对精美的图标,这里使用的是svg格式,因为这样方便修改宽高和颜色,让整体颜色比较和谐统一。
tsx
// index.tsx
import SvgIcon from '../../assets/SvgIcon';
import DragItem from './DragItem';
const EleSource = () => {
return (
<div className="MediaLayout-EleSource">
<DragItem stringData="caption">
<SvgIcon.Caption width={28} height={28} />
</DragItem>
<DragItem stringData="time">
<SvgIcon.Time width={36} height={36} />
</DragItem>
<DragItem stringData="date">
<SvgIcon.Date width={58} height={58} />
</DragItem>
<DragItem stringData="week">
<SvgIcon.Week />
</DragItem>
<DragItem stringData="image">
<SvgIcon.Image width={38} height={38} />
</DragItem>
<DragItem stringData="video">
<SvgIcon.Video width={38} height={38} />
</DragItem>
</div>
);
};
export default EleSource;
DragItem:注册拖拽物
tsx
import { useDrag } from 'ahooks';
import { ReactNode, useRef } from 'react';
type DragItemProps = {
children: ReactNode;
stringData: TEleType;
};
const DragItem = ({ children, stringData }: DragItemProps) => {
const dragRef = useRef(null);
// 第一个参数指携带的数据源(字符串),当它被拖拽到某个区域后,那个区域能接收到这个数据源
useDrag(stringData, dragRef, {
onDragStart: () => {// 拖拽时的样式
dragRef.current.style.border = 'dashed';
dragRef.current.style.opacity = 0.5;
},
onDragEnd: () => {// 拖拽结束后取消样式
dragRef.current.style.border = 'none';
dragRef.current.style.opacity = 1;
},
});
return (
<div className="DragItem" ref={dragRef}>
{children}
</div>
);
};
export default DragItem;
4.4 画布容器EleCanvas
tsx
// index.tsx
import React, { useRef } from 'react';
import useContextHandler from '../useContextHandler';
import { GridLayout } from 'react-grid-layout-next';
import {
generateGridEles,// 生成react-grid-layout库要求的元素
getUuidFromLayoutEleKey,
TxtUtil,
CaptionUtil,
FormatEleProps,
} from '../helper';
import { deepClone, getUuid, FloatFormater } from '../../utils';
import { GridDefault } from '../constant'; // 元素默认值
import { useDrop } from 'ahooks';
const EleCanvas = React.forwardRef((_, ref) => {
let {
viewSize,
widthRatio,
heightRatio,
eleList,
setEleList,
setSelectedEleKey,
selectedEleKey,
baseInfo,
} = useContextHandler();
const [width, height] = viewSize; // 转化过后的画布宽高
const containerStyle = { width: `${width}px`, height: `${height}px` };
// 过滤掉背景音乐类型,因为它不需要布局
const showedEles = eleList.filter(
(item) => item.type !== 'audio'
) as Array<TEleWithLayout>;
// 拖拽区域
const dropRef = useRef(null);
// 1. 将EleSource的元素 拖拽进区域的操作
useDrop(dropRef, {
onDom: (type: string, e: React.DragEvent) => {
const newItem = deepClone(GridDefault[type]); // 根据类型获取初始值
if (newItem) {
newItem.uuid = getUuid();
newItem.type = type;
FormatEleProps(newItem, baseInfo); // 主要是对字体属性做了处理 fontsize -> width \ height
// 这里的e是鼠标。
// e.layerX是相对于父元素的偏移量,是真实数据,
// 而eleList存储的是原数据,所以需要把真实数据按比率转为原始数据
newItem.x = e.layerX * widthRatio;
// y点不能直接设定,因为放下后,加上元素的高度,元素可能超出容器
if (
e.layerY + FloatFormater(newItem.height / heightRatio, 0) >
height
) {
// 1. 当 y+元素高度 超过画布高度,那元素: y = 画布高度-元素高度
newItem.y = height * heightRatio - newItem.height;
} else {
// 2. 否则就拿鼠标的位置作为y
newItem.y = e.layerY * heightRatio;
}
eleList.push(newItem);
setEleList(() => [...eleList]);
}
},
});
// 2. 画布内拖拽
const handleMove = (prop) => {
const targetItem = prop.item;// 当前正在拖拽的元素,这个值是这个库规定好的对象,与现在的元素对象不是一个概念
const layoutUuid = getUuidFromLayoutEleKey(targetItem.i);
// 这个i是在uuid的基础上经过处理的,因为uuid不能作为key,
// 因为元素在画布中,还有x y w h等属性,当这些布局属性变化后,这个元素应该也要重新渲染
const idx = eleList.findIndex((ele) => ele.uuid === layoutUuid);
if (idx !== -1) {
setSelectedEleKey(layoutUuid); // 拖拽结束后 选中这个元素 方便后续编辑属性
const newEle = deepClone(eleList[idx]) as TEleWithLayout;
const { x, y } = targetItem;
newEle.x = x * widthRatio; // 按比率转为原数据
newEle.y = y * heightRatio;
eleList[idx] = newEle;
}
setEleList([...eleList]);
};
// 3. 元素在画布内伸缩
const handleResizeEle = (prop) => {
const targetItem = prop.item;
const layoutUuid = getUuidFromLayoutEleKey(targetItem.i);
const idx = eleList.findIndex((ele) => ele.uuid === layoutUuid);
if (idx !== -1) {
setSelectedEleKey(layoutUuid);
const newEle = deepClone(eleList[idx]) as TEleWithLayout;
const { w, h } = targetItem;
newEle.width = w * widthRatio;
newEle.height = h * heightRatio;
// 对应普通文字来说,要根据它的高度去推出文字大小,同时计算出合适的宽度,避免空隙留很大
if (TxtUtil.isType(newEle.type)) {
TxtUtil.formatFontSize(newEle);
}
eleList[idx] = newEle;
}
setEleList([...eleList]);
};
return (
<div className="MediaLayout-EleCanvas" ref={ref}>
<div
className="MediaLayout-EleCanvas-PreviewBox"
style={containerStyle}
ref={dropRef}
>
{width !== 0 && (
<GridLayout
className="layout"
width={width}
cols={width}
rowHeight={1}
margin={[0, 0]}
style={containerStyle}
compactType={null} // 不附着
allowOverlap={true} // 允许元素交叠
// onLayoutChange={handleChangeLayout}
// onLayoutChange 其实可以代替onDragStop和onResizeStop
// 一旦布局内元素变化(包括位置大小),都会触发,
// 不过它回传来的值是所有元素,意味着很难找到当前正在操作的元素
// 计算量会比后面两者多一些,而且对于后续扩展也是不方便的
onDragStop={handleMove}
onResizeStop={handleResizeEle}
isBounded={true} // 防止出界
>
{generateGridEles(showedEles, {
selectedEleKey,// 用于设置选中样式
setSelectedEleKey, // 用于点击元素后,修改选中元素
widthRatio,// 用于转换属性值
heightRatio,
})}
</GridLayout>
)}
</div>
</div>
);
});
export default EleCanvas;
插播一句。
这里有个地方搞了我很久,就是元素右下角的伸缩handler,按照文档来说,我默认应该是能见到伸缩handler的,但是没有(可能因为我没导入样式?)。于是去翻阅了一下文档,它其实暴露了一个接口resizeHandle,好的,handler元素写进去了,结果是有这么个handler,但是伸缩功能失效了。于是参考别人用了另一种简单粗暴的方法,如下图所示,这个库本身就给元素内部搞了这么一个dom,它一开始是空的,现在只需要重写下这个类,就可以了
css
/* 缩放手柄 */
.react-resizable-handle {
position: absolute;
width: 20px;
height: 20px;
bottom: 0;
right: 0;
background: url('./assets/resize.svg');
background-position: bottom right;
background-repeat: no-repeat;
background-origin: content-box;
box-sizing: border-box;
cursor: se-resize;
padding: 0 2px 2px 0;
}
generateGridEles:生成库要求的元素
tsx
export const generateGridEles = (
eles: Array<TEleWithLayout>,
{ selectedEleKey, setSelectedEleKey, widthRatio, heightRatio }
) => {
return eles.map((item) => {
const { uuid, x, y, width, height } = item;
let className = '';
// 生成内容
let label = getGridEleLabel(item, { widthRatio });
// 生成grid元素的key:uuid无法标识画布中的元素,因为每个元素的x y w h属性,画布分辨率变化后,也需要重新渲染
const key = `${uuid}:${x}-${y}-${width}-${height}-${widthRatio}-${heightRatio}`;
// 把原数据 按比率 转为画布中的数据,
const GridProps = getGridEleProps(item, {
widthRatio,
heightRatio,
});
if (item.uuid === selectedEleKey) {
className += ' selected';
}
return (
<div key={key} data-grid={GridProps} className={`GridEle ${className}`}>
<div
className={`GridEle-LabelContainer`}
onClick={() => {
setSelectedEleKey(item.uuid);
}}
>
{label}
</div>
</div>
);
});
};
getGridEleLabel:按元素类型生成内容
tsx
const getGridEleLabel = (ele: TEle, { widthRatio }) => {
const { date, time, week } = getDate();
let style = {};
// 文字元素中的颜色和字体大小,需要作为style。其中字体大小是原始数据,也需要按比率转化为画布数据
if (TxtUtil.isType(ele.type)) {
style = TxtUtil.getStyle(ele as TTxtEle, { widthRatio });
}
if (CaptionUtil.isType(ele.type)) {
style = CaptionUtil.getStyle(ele as TCaptionEle, { widthRatio });
}
// 日期 时间 星期的值是实时生成的
switch (ele.type) {
case 'date':
return <span style={style}>{date}</span>;
case 'time':
return <span style={style}>{time}</span>;
case 'week':
return <span style={style}>{week}</span>;
case 'image':
// 如果文件为空,给一个默认内容:图标
if ((ele as TMediaEle).files.length === 0) {
return <SvgIcon.Image />;
}
// 如果文件不为空,使用走马灯展示
return (
<Carousel autoplay>
{(ele as TMediaEle).files.map((file) => (
<div key={file.id}>
<img
src={`http://127.0.0.1:8000/${file.path}`}
width="100%"
height="100%"
/>
</div>
))}
</Carousel>
);
case 'video': {
// 如果为空,给一个默认内容
if ((ele as TMediaEle).files.length === 0) {
return <SvgIcon.Video />;
}
// 这个是通过第三方库reactjs-video-playlist-player封装出来的组件,是视频文件连续播放
return <VideoPlayer files={(ele as TMediaEle).files} />;
}
case 'caption': {
// 如果是静止 直接用span元素
if (ele.direction === 0) {
return <span style={style}>{(ele as TCaptionEle).content}</span>;
}
// 否则用第三方库react-fast-marquee
return (
<Marquee
style={style}
direction={ele.direction === 1 ? 'left' : 'right'}
speed={ele.speed}
>
{(ele as TCaptionEle).content}
</Marquee>
);
}
default:
return null;
}
};
源码
这个功能我是在stackblitz写的,没有在实际环境中跑过,因为电脑太拉跨。据我所知,它的node版本好像是18或20,如果本地跑不起来,可能是node版本不够高?实在不行可以把仓库拷到stackblitz运行。
因为不能直接导入媒体文件进stackblitz,所以我的文件都放在了Apache服务器,所以这个源码是不包括媒体文件的,需要自己搞。
后记
这原来项目的一个功能,但是组件间的相互通信写得不太好,采用了父子层层通信,维护和扩展的时候非常刺激。由于我觉得这个功能挺新鲜的,有东西可以学习,并且原来的布局和交互还有提升空间,代码还可以写得更优雅,所以我重新写了一遍。额...实实在在地写了我好几天(吐血)
其实还有很多细节可以完善,比如实现多个音频连续播放、给字幕元素加上伸缩功能、判断给字幕调整了fontsize后会不会超出画布等等。but,反正大体功能搞定了,我原本给自己的总结任务也完成了,躺平了躺平了...