让多媒体元素在既定容器中自由布局

一 功能

  1. 可添加时间、日期、星期、字幕、图片、视频和背景音乐。
  2. 可修改布局大小。画布及元素的个别属性(如x,y,width,height,fontsize)将会通过一定比例进行缩放,以此达到接近实际所看到的效果。
  3. 可通过拖拽修改元素位置、添加新元素;可对元素进行收缩以改变其尺寸等属性。
  4. 支持修改时间、日期、星期的颜色、大小;支持修改字幕的颜色、大小、滚动方向、滚动速度;支持对图片元素/视频元素添加多个文件,根据不同的类型,文件列表会过滤出对应的文件类型。

二 效果

预览:https://sanhuamao-1302890440.cos-website.ap-guangzhou.myqcloud.com/

三 结构介绍

3.1 第三方库

  • reactjs-video-playlist-player@1.1.1:实现让多个视频文件连续播放
  • react-grid-layout-next@2.2.0:让元素在画布中布局与缩放

    这是基于react-grid-layout写的

  • ahooks@3.7.8:用到了里面的useSize(监听画布变化)、useDraguseDrop(实现从外部拖拽元素进画布)。
  • react-fast-marquee:实现字幕滚动

3.2 视图结构

MediaLayout

<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 数据结构

原始数据

{
  program_name: '节目名称',
  program_width: 1920,
  program_height: 1080,
  eles: [], // 元素列表。里面的元素属性不一定与画布中的元素属性一致。
  // 1. 元素列表的属性是原始数据,画布中布局相关的属性是经过比率转换过的数据
  // 2. 这里元素属性的数量不一定与画布中元素属性相同。
  //    比如对于字幕元素,实际情况是只有fontsize,但在画布中我们还需要宽高来做视觉效果,
  //    这个宽高是从fontsize计算而来的,是画布元素中多出来的属性
}

元素对象

// 基本信息
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>;

文件元素

type TFile = {
  id: string;
	 type:'image'|'audio'|'video';
  path: string;
};
type TFileList = Array<TFile>;

3.4 原数据宽高与画布宽高

一般情况下,原数据的宽高画布,比如1920 x 1080,是很难直接用到电脑屏幕上的。这里是通过同比例缩小来解决这个问题的。
首先,我们的视图有一个组件EleCanvas容器,里面放处理后的画布(等比例缩小后的画布)。下面是一个计算过程:

// 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 通信与联动

这里以兄弟间通信为主,采用了上下文的方式。

  1. 捋一捋整个过程中组件间会相互用到的状态,并创建上下文:createContext
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;

其中下面几个使用最为频繁:

1. selectedEleKey:选中元素的key。需要在元素列表(EleList)、元素画布(EleCanvas)、元素属性(ElePropPanel)用到

2. eleList:元素列表。需要在元素列表(EleList)、元素画布(EleCanvas)用到

3. widthRatio:节目宽对于画布宽的比率。转换元素属性值时用到

4. heightRatio:节目高对于画布高的比率。转换元素属性值时用到

  1. 将数据与修改数据的方法提供给各个组件:LayoutContext.Provider
// 初始数据
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(initData.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;
  1. 在组件内使用上下文传递过来的属性:useContext
import LayoutContext from './Context';
import { useContext } from 'react';

let { eleList,...} = useContext(LayoutContext);

其实这样很麻烦,因为每次进入一个新组件,都要导入LayoutContextuseContext,然后再拿到东西。于是乎我封装了钩子useContextHandler,避免频繁引用LayoutContextuseContext的操作,并且还在里面扩展了一些操作:

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

//  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

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

image.png

// 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生成对应的表单组件

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:文件选择器

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格式,因为这样方便修改宽高和颜色,让整体颜色比较和谐统一。

// 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:注册拖拽物

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

image.png

// 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;

插播一句。

image.png

这里有个地方搞了我很久,就是元素右下角的伸缩handler,按照文档来说,我默认应该是能见到伸缩handler的,但是没有(可能因为我没导入样式?)。于是去翻阅了一下文档,它其实暴露了一个接口resizeHandle,好的,handler元素写进去了,结果是有这么个handler,但是伸缩功能失效了。于是参考别人用了另一种简单粗暴的方法,如下图所示,这个库本身就给元素内部搞了这么一个dom,它一开始是空的,现在只需要重写下这个类,就可以了
image.png

/* 缩放手柄 */
.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:生成库要求的元素

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:按元素类型生成内容

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

源码

https://github.com/sanhuamao1/stackblitz-mediaLayout

后记

这原来项目的一个功能,但是组件间的相互通信写得不太好,采用了父子层层通信,维护和扩展的时候非常刺激。由于我觉得这个功能挺新鲜的,有东西可以学习,并且原来的布局和交互还有提升空间,代码还可以写得更优雅,所以我重新写了一遍。额...实实在在地写了我好几天(吐血)

其实还有很多细节可以完善,比如实现多个音频连续播放、给字幕元素加上伸缩功能、判断给字幕调整了fontsize后会不会超出画布等等。but,反正大体功能搞定了,我原本给自己的总结任务也完成了,躺平了躺平了...

posted @ 2023-11-30 10:08  sanhuamao  阅读(35)  评论(0编辑  收藏  举报