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

一 功能

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

二 效果

可在这里直接操作: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(监听画布变化)、useDraguseDrop(实现从外部拖拽元素进画布)。
  • 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 通信与联动

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

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

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

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

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

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

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

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

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

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

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

源码

github.com/sanhuamao1/...

这个功能我是在stackblitz写的,没有在实际环境中跑过,因为电脑太拉跨。据我所知,它的node版本好像是18或20,如果本地跑不起来,可能是node版本不够高?实在不行可以把仓库拷到stackblitz运行。

因为不能直接导入媒体文件进stackblitz,所以我的文件都放在了Apache服务器,所以这个源码是不包括媒体文件的,需要自己搞。

后记

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

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

相关推荐
kyriewen2 分钟前
你的网页慢,用户不说直接走——前端性能监控教你“读心术”
前端·性能优化·监控
广州华水科技3 分钟前
北斗GNSS变形监测在大坝安全监测中的应用与优势分析
前端
前端老石人14 分钟前
前端开发中的 URL 完全指南
开发语言·前端·javascript·css·html
CAE虚拟与现实14 分钟前
五一假期闲来无事,来个前段、后端的说明吧
前端·后端·vtk·three.js·前后端
Sarvartha25 分钟前
三目运算符
linux·服务器·前端
晓晨的博客32 分钟前
ROS1录制的bag包转换为ROS2格式
前端·chrome
Wect40 分钟前
LeetCode 72. 编辑距离:动态规划经典题解
前端·算法·typescript
donecoding1 小时前
别再让 pnpm 跟着 nvm 跑了!独立安装终极指南
前端·node.js·前端工程化
GISer_Jing1 小时前
AI全栈转型_TS后端学习路线
前端·人工智能·后端·学习
竹林8181 小时前
被The Graph的GraphQL查询坑了三天,我用一个真实DeFi项目把链上数据索引彻底搞懂了
前端·graphql