首页 > 其他分享 >让多媒体元素在既定容器中自由布局

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

时间:2023-11-30 10:12:25浏览次数:40  
标签:容器 多媒体 元素 return 画布 import const type 既定

一 功能

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

二 效果

可在这里直接操作:https://stackblitz-starters-vn7gmq.stackblitz.io/

三 结构介绍

3.1 第三方库

  • [email protected]:实现让多个视频文件连续播放
  • [email protected]:让元素在画布中布局与缩放

    这是基于react-grid-layout写的。原本使用react-grid-layout遇到了各种问题诸如onLayoutChange会调用两次、allowOverlap为ture时onLayoutChange不生效、拖拽点击不能同时使用等问题,总而言之言而总之最后用了这个库

  • [email protected]:用到了里面的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(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
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

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

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

后记

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

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

标签:容器,多媒体,元素,return,画布,import,const,type,既定
From: https://www.cnblogs.com/sanhuamao/p/17866656.html

相关文章

  • Docker 容器日志查看和清理
    ......
  • PVE 系列之二:安装基于 LXC 的容器
    基于LXC的容器CT模板换源注意版本是否适配cp/usr/share/perl5/PVE/APLInfo.pm/usr/share/perl5/PVE/APLInfo.pm_backsed-i's|http://download.proxmox.com|https://mirrors.tuna.tsinghua.edu.cn/proxmox|g'/usr/share/perl5/PVE/APLInfo.pm重启服务。systemctlr......
  • 用户指南:基于手势识别的多媒体辅助控制系统
    用户指南:基于手势识别的多媒体辅助控制系统目录用户指南:基于手势识别的多媒体辅助控制系统简介手势识别模式休眠模式默认模式鼠标模式主界面音乐界面指南视频界面指南pdf界面指南可改进方向关于我们简介本系统利用手势识别辅助浏览mp3音乐文件、mp4视频文件、pdf图文文件。手......
  • 五、容器数据卷(Volume)
    1.什么是容器数据卷先来看看Docker的理念:将运用与运行的环境打包形成容器运行,运行可以伴随着容器,但是我们对数据的要求希望是持久化的容器之间希望有可能共享数据Docker容器产生的数据,如果不通过dockercommit生成新的镜像,使得数据做为镜像的一部分保存下来,那么当容器......
  • Docker + supervisor在同一容器中部署zookeeper和kafka
    使用supervisor进程管理工具,在同一个容器中部署zookeeper和kafka目录Dockerfilejdk1.8.0_181.tar.gzkafka_2.12-1.1.0.tgzconf.ddocker-compose.ymlkafka_conf.dconf.d中为supervisor配置文件kafka_conf.d中为kafka配置文件,解压kafka_2.12-1.1.0.tgz中的配置文件,拷贝......
  • C++容器中存放的是数据本身还是数据地址?
    在C++中,std::map容器内存放的是数据本身(即键值对的值部分),而不是数据地址。当我们插入一个键值对时,std::map会自动复制值并存储副本voidtest02(){ multimap<int,Worker>m; Workerw; w.name="sd"; w.salary=1234; m.insert(pair<int,Worker>(1,w)); multimap<i......
  • c++ deque容器
    一、deque介绍deque(双端队列)是一种索引容器,它包含在#include<deque>头文件中。它与普通的queue队列不同的是,deque可以实现在尾部插入和删除元素。随机的访问双端队列中的元素,时间复杂度为O(1)在首部或者尾部插入或删除元素,时间复杂度O(1)插入和删除元素,是线性的,时间复杂度为O......
  • kubernetes集群使用容器镜像仓库Harbor
    1、容器镜像仓库Harbor部署在docker主机部署Harbor,安装过程比较简单在k8s集群中部署Harbor2、使用Harbor仓库2.1通过secret使用Harbor仓库新建私有仓库集权所有节点配置harbor仓库#cat/etc/docker/daemon.json{"exec-opts":["native.cgroupdriver=system......
  • docker-compose种不通的服务之间的访问问题,夸容器访问
    背景我们知道对于docker的每个容器都是独立的,想要夸容器访问的话,不能用127.0.0.1加端口号去访问,所以需要docker虚拟网卡的网关分配的地址去访问,可以通过dockerinspect对每个容器的局域网ip进行查看,但是这样比较麻烦,所以有一个新的解决办法,就是通过docker-compose配置文件的方......
  • Spring配置文件的魔法炼金术:如何制造容器化时代的完美配方
    前言基于现代服务的云原生十二要素理论,我们在采用容器化部署时,要保证同一个镜像可以满足不同环境的部署要求,而不是不同环境打包不同的镜像。本文档主要介绍一种基于spring框架的满足不同环境配置的编译打包方案,满足同一个镜像可以在环境分组下通过启动项配置实现不同环境的部署。......