简单实现一下 腾讯文档 [画册板块]

1,941 阅读6分钟

首先进行功能分析和 dom 结构分析

  1. 采用了 canvas 渲染 ( 如果不是 canvas 渲染 那就没必要去实现... ),多画布结构,一个layer用于静态数据展示,另外一个 layer 用于频繁更新渲染的用户操作。
  2. 画册卡片的宽度是一个区间值,并非固定 暂定 250 <= width <=325,它会随着可视区域的宽度动态调整。
  3. 卡片数量超过整体的高度超过画布可视区域会出现滚动条,这个滚动条也是用 canvas实现的,那我们也就不要用 div 来模拟。
  4. 可以拖动某个卡片到其他行列卡片进行排序,存在滚动条的情况下,拖动到顶部 | 底部边界还会自动滚动。

接下来我们一一的实现这些功能。 image.png

1、canvas 渲染 腾讯文档整体使用了 React + mobx + konva,这块我们也同样采用这些实现。

import { createContext, PropsWithChildren } from "react";
import { OperationStore } from "../Stores/Operation";
import { StageManager } from "../Stores/StageManager";
import { DataManagement } from "../Stores/DataManagement";

export const AlbumPaintingStore = createContext(
  {} as {
    // 列表数据管理器
    dataManager: DataManagement,
    // 画布数据管理器
    stageManager: StageManager,
    // 其他响应数据管理
    operationStore: OperationStore,
  }
);
//  画册
export function AlbumPaintingProvider({ children }: PropsWithChildren) {
  const [dataManager] = useState(() => new DataManagement())
  const [stageManager] = useState(() => new StageManager(dataManager))
  const [operationStore] = useState(() => new OperationStore(dataManager));
  return (
    <AlbumPaintingStore.Provider
      value={{
        dataManager,
        stageManager,
        operationStore
      }}>
      {children}
    </AlbumPaintingStore.Provider>
  )
}

export const AlbumPainting = () => {
  return (
    <AlbumPaintingProvider>
      <AlbumPaintingStage
        ItemChildrenRender={(props) => <Item {...props} />}
        //  可自定义
        ActiveDragElement={(props) => <Item {...props} />}
        StageWidth={800}
        StageHeight={600}
        autofit={false}
      />
    </AlbumPaintingProvider>
  )
}

首先定义出画册模块的状态管理 各自的作用我用注释标记。 画布使用 react-konva

import { observer } from "mobx-react-lite";
import { Group, Layer, Rect, Stage } from "react-konva";
import { Portal } from "react-konva-utils";
import { createElement, PropsWithChildren, ReactNode } from "react";
import Konva from "konva";

import { ItemProps } from "./model";
import { useAlbumPaintingStore } from "../hooks";

interface Props {
  /**
   * 子项渲染
   */
  ItemChildrenRender: (props: ItemProps) => ReactNode;
  /**
   * 克隆项渲染
   */
  ActiveDragElement?: (props: ItemProps) => ReactNode;
  /**
   * 固定画布宽度
   */
  StageWidth?: number;
  /**
   * 画布高度
   */
  StageHeight?: number;
  /**
   * 自适应
   */
  autofit?: boolean;
}

export type ExtractProps = Pick<Props, 'StageHeight' | 'StageWidth' | 'autofit'>;

export const AlbumPaintingStage = observer((
  {
    children,
    ItemChildrenRender,
    ActiveDragElement = ItemChildrenRender,
    ...props
  }: PropsWithChildren<Props>) => {

  const { stageManager } = useAlbumPaintingStore();
  const stageRef = useRef<Konva.Stage>(null);
  const layerRef = useRef<Konva.Layer>(null);
  const verticalBarRef = useRef<Konva.Rect>(null);
  const horizontalBarRef = useRef<Konva.Rect>(null);

  useEffect(() => {
    stageManager.registerRefs({
      stage: stageRef.current!,
      layer: layerRef.current!,
      verticalScroll: verticalBarRef.current!,
      horizontalScroll: horizontalBarRef.current!
    });
  }, [])

  useEffect(() => {
    let effect: () => void;
    if (props.autofit) {
      effect = stageManager.resize();
    }
    return () => void effect?.();
  }, [])


  return (
    <Stage
      width={stageManager.width}
      height={stageManager.height}
      ref={stageRef}
      id="stageContainer"
      style={{ background: '#F3F5F7', flex: 1 }}
      onWheel={(event) => {
        event.evt.preventDefault();
        const deltaY = event.evt.deltaY;
        // 每次滚动的步长
        const step = 50;
        // 根据滚动方向更新 scrollTop
        const newScrollTop = Math.max(
          0,
          Math.min(stageManager.scrollTop + (deltaY > 0 ? step : -step),
            stageManager.CANVAS_HEIGHT - stageManager.VIEWPORT_HEIGHT)
        );
        // 更新滚动条位置 && 更新视图渲染
        stageManager.setScrollTop(newScrollTop);

        //  更新画布的 y 坐标
        const availableHeight = stageRef.current!.height() - stageManager.PADDING * 2 - stageManager.calculateBarHeight;
        const barY = stageManager.PADDING + (newScrollTop / (stageManager.CANVAS_HEIGHT - stageManager.VIEWPORT_HEIGHT)) * availableHeight;
        horizontalBarRef.current!.y(barY);
      }}
      onMouseup={stageManager.stageMouseup.bind(stageManager)}
      onMousemove={stageManager.handleMouseMove.bind(stageManager)}
    >
      <Layer
        onMouseDown={stageManager.handleMouseDown.bind(stageManager)}
        ref={layerRef}>
        <Portal selector=".controls-layer">
          <Group visible={stageManager.portalGroupVisible}>
            <Rect
              {...stageManager.dragingHighlightLine}
              width={2}
              height={stageManager.rowHeight - 10}
              fill="#003cab"
              visible={stageManager.highlightLineVisible}
              draggable={true}
            />
            <Group
              {...stageManager.cloneCardGroupPosition}
              width={stageManager.draggerRect?.width || 0}
              height={stageManager.draggerRect?.height || 0}
            >
              {createElement(ActiveDragElement, stageManager.draggerRect as any)}
            </Group>
          </Group>
          <Rect
            width={8}
            height={stageManager.calculateBarHeight}
            fill={'#C0C4C9'}
            // opacity={0.2}
            x={stageManager.horizontalX}
            y={0}
            visible={stageManager.isShowScrollRect}
            cornerRadius={[10, 10, 10, 10]}
            draggable
            ref={horizontalBarRef}
            dragBoundFunc={(position) => {
              position.x = stageRef.current!.width() - stageManager.PADDING;
              position.y = Math.max(
                Math.min(position.y, stageRef.current!.height() - stageManager.PADDING - stageManager.calculateBarHeight),
                stageManager.PADDING
              );
              return position;
            }}
            onDragMove={() => {
              if (stageRef.current && horizontalBarRef.current) {
                const barY = horizontalBarRef.current.y();
                const availableHeight = stageRef.current.height() - stageManager.PADDING * 2 - stageManager.calculateBarHeight;
                const newScrollTop = ((barY - stageManager.PADDING) / availableHeight) * (stageManager.CANVAS_HEIGHT - stageManager.VIEWPORT_HEIGHT);
                stageManager.setScrollTop(Math.ceil(newScrollTop));
              }
            }}
          />
          {/* 横向滚动条 */}
          {/* <Rect
            width={100} // 滚动条宽度
            height={10} // 滚动条高度
            fill={'grey'}
            opacity={0.3}
            x={stageManager.PADDING} // 左侧边距
            y={stageRef.current?.height() - stageManager.PADDING - 10} // 底部位置
            draggable
            ref={verticalBarRef}
            cornerRadius={[10, 10, 10, 10]}
            dragBoundFunc={(pos) => {
              pos.y = stageRef.current.height() - stageManager.PADDING - 10; // 固定 Y 轴位置
              pos.x = Math.max(
                Math.min(pos.x, stageRef.current.width() - stageManager.PADDING - 100),
                stageManager.PADDING
              );
              return pos;
            }}
            onDragMove={() => {
              if (stageRef.current && verticalBarRef.current && layerRef.current) {
                const barX = verticalBarRef.current.x();
                const availableWidth = stageRef.current.width() - stageManager.PADDING * 2 - verticalBarRef.current.width();
                const delta = (barX - stageManager.PADDING) / availableWidth;
                layerRef.current.x(-(WIDTH - stageRef.current.width()) * delta);
                // layerRef.current.getLayer().batchDraw();
              }
            }}
          /> */}
        </Portal>
        <Group id='one' offsetY={stageManager.scrollTop} offsetX={stageManager.scrollLeft}>
          {stageManager.showItems.map(({ x, y, width, height, rowIndex, columnIndex }) => {
            return createElement(ItemChildrenRender, {
              x,
              y,
              width,
              height,
              rowIndex,
              columnIndex,
              key: `${rowIndex}:${columnIndex}`,
            })
          })}
        </Group>
      </Layer>
      <Layer
        name="controls-layer"
        height={stageManager.CANVAS_HEIGHT}
        width={600}
        visible={true}
      />
    </Stage>
  )
})

画册卡片的宽度

 calculateOptimalCardCount(
    canvasWidth: number,
    minCardWidth: number,
    maxCardWidth: number,
    margin = 10
  ) {
    let optimalCount = 0;
    let optimalWidth = 0;

    // 从最小宽度到最大宽度逐步计算
    for (let width = minCardWidth; width <= maxCardWidth; width++) {
      // 每个卡片的宽度加上边距
      const totalWidthWithMargin = width + margin;

      // 计算可以放下的卡片数量,需要减去左边距
      const count = Math.floor((canvasWidth + margin) / totalWidthWithMargin);

      // 如果当前宽度的卡片数量大于之前记录的最佳数量,更新最佳数量和宽度
      if (count > optimalCount && count > 0) {
        optimalCount = count;
        optimalWidth = width;
      }
    }
    // 计算剩余空间并均摊
    const totalUsedWidth = optimalCount * (optimalWidth + margin);
    const remainingSpace = canvasWidth - totalUsedWidth;
    // 每个卡片分配的额外宽度
    const additionalWidthPerCard = optimalCount > 0 ? remainingSpace / optimalCount : 0;
    // 计算新的卡片宽度
    const finalWidth = optimalWidth + additionalWidthPerCard;
    return { optimalCount, optimalWidth, finalWidth };
  }

这块儿相对简单一些 只需要计算得到 列宽 和 列的数量。 这样的设计为我们省去了横向滚动条的烦恼, 只需要考虑纵向滚动条的逻辑。

3. 滚动条的实现

上面贴出了滚动条使用Rect模拟实现 ,重点关注两个函数,刚好提供了onDragMove事件用于更新距离顶部的距离 dragBoundFunc函数实现逻辑限制 Rect 只能纵向滚动。滚动的高度也需要通过卡片的数量计算

  // 计算滚动条的高度
  get calculateBarHeight() {
    const barHeight = (this.VIEWPORT_HEIGHT / this.CANVAS_HEIGHT) * this.VIEWPORT_HEIGHT; // 滚动条高度
    const height = Math.min(Math.max(barHeight, 20), this.VIEWPORT_HEIGHT); // 最小高度限制为 20
    return height;
  };

滚动条移动 会setScrollTop设置距离顶部的值,从而引发画布中卡片的重新渲染

 render() {
    const { finalWidth, optimalCount } = this;
    const starttime = performance.now();
    // 使用scrollTop来计算起始和结束行索引
    const { startRowIndex, endRowIndex } = calculator.getVisibleRowIndices({
      rowHeight: this.rowHeight,
      rowCount: this.rowLength,
      offset: this.scrollTop,
      containerHeight: this.height, // 假设有一个容器高度
    });

    // 处理边界情况
    if (endRowIndex >= this.rowLength || startRowIndex < 0) {
      return;
    }

    const items: Required<Item>[] = [];
    const nums = startRowIndex * optimalCount;

    // 渲染可见的卡片
    for (let i = startRowIndex; i <= endRowIndex; i++) {
      const yCoordinate = this.getYCoordinate(i);

      for (let j = 0; j < optimalCount; j++) {
        const xCoordinate = j * finalWidth;
        const currentCardCount = (i - startRowIndex) * optimalCount + j;

        // 检查当前卡片是否超出总卡片数量
        if (currentCardCount + nums < this.cards) {
          items.push({
            x: xCoordinate + 10,
            y: yCoordinate,
            width: finalWidth - 10,
            height: this.rowHeight - 10,
            key: `${i}:${j}`,
            rowIndex: i,
            columnIndex: j,
            title: '',
            description: '',
          });
        }
      }
    }
    const endtime = performance.now();
    console.log('渲染耗时:', (endtime - starttime) / 1000, '秒');
    this.showItems = items as IObservableArray<Required<Item>>;
  }

calculator.getVisibleRowIndices的作用是通过当前距离顶部的值,计算出可视区域可显示的 startRowindex 和 enRowindex来实现动态渲染,同时将scrollTop作为key缓存计算过的startRowindex 和 enRowindex。

4拖动排序 & 滚动

onMousemove={stageManager.handleMouseMove.bind(stageManager)} 我将 move 事件添加到了 Stage上并非Layer上,因为在拖动滚动的过程中,被拖动克隆的卡片所在的 layer 层级会被提到最上面,move 的时候会出现底层的move事件失效。

  handleMouseMove(event: any): void {
    if (!this.isDragging) {
      return;
    }
    const stage = event.target.getStage()!;
    const point = stage.getPointerPosition()!;

    if (!this.draggerRect) {
      this.cloneDraggerRect();
      document.body.style.cursor = 'grabbing';
    }
    const currentY = point.y; // 记录开始拖动时的 Y 坐标
    //  向上拖动
    if (point.y - this.lastY < 0) {
      if (point.y <= 10 && !this.isInAnimationFrame) {
        this.requestFrameScroll('top');
      }
      this.horizontalScrollDirection = 'top';
    } else {
      // 向下拖动
      if (point.y >= this.height - 10 && !this.isInAnimationFrame) {
        this.requestFrameScroll('bottom');
      }
      this.horizontalScrollDirection = 'bottom';
    }
    this.lastY = currentY;
    const rect = this.groupRef.targetRect;
    //  卡片原型
    this.cloneCardGroupPosition = {
      x: rect!.x + (point.x - rect!.x) - (rect?.point.x! - rect!.x) - 10,
      y: rect!.y + (point.y - rect!.y) - (rect?.point.y! - rect!.y) - 10,
    }

    //  计算拖动位置与哪一个 group 相交
    const pointer = event.target.getStage()?.getPointerPosition();
    (this.LayerRef!.children[1] as any).children.forEach((group: typeGroup, i: number) => {
      const rect = group.getClientRect();
      if (this.haveIntersection(rect, pointer)) {
        //  origin group === current drag group
        if (group.attrs.id === this.groupRef?.target!.attrs.id) {
          this.dragingHighlightLine = { x: 0, y: 0 };
          return;
        }

        // 判断相交的group 在相对于拖动源的哪个方向 (左 | 右)
        const target = this.groupRef?.targetRect!
        let x = 0;
        //  同一行
        if (target.y === rect.y) {
          x = rect.x < this.groupRef?.targetRect!.x ? rect.x : rect.x + rect.width - 10;
        } else {
          // 获取相交元素的中心点
          const rectCenterX = rect.x + rect.width / 2;
          // 判断当前拖动坐标是否超过了相交元素的一半
          if (pointer.x > rectCenterX) {
            x = rect.x + rect.width - 10;
          } else {
            x = rect.x;
          }
        }
        this.dragingHighlightLine = {
          x,
          y: rect.y
        }
      }
    });
  }

这段代码中有几个重要的部分

  • this.cloneDraggerRect(); 克隆拖动起始卡片
  • this.requestFrameScroll; 开启滚动动画
  • 计算拖动位置与哪一个 group 相交; 判断相交
  cloneDraggerRect(): void {
    const groupid = this.groupRef.target?.attrs.id;
    const group = this.showItems.find((group) => group.key === groupid) as ItemProps;
    if (!group) {
      return;
    }

    this.draggerRect = Object.assign({},
      { ...group, x: 10, y: 10, rowIndex: 10000, columnIndex: 100000 })
  }
  
  <Group
   {...stageManager.cloneCardGroupPosition}
   width={stageManager.draggerRect?.width || 0}
   height={stageManager.draggerRect?.height || 0}
    >
     {createElement(ActiveDragElement, stageManager.draggerRect as any)}
   </Group>
  private requestFrameScroll(scrollType: 'top' | 'bottom'): void {
    this.isInAnimationFrame = true;
    const { start, stop } = framesync(() => {
      const deltaY = 10;
      const step = scrollType === 'top' ? -(this.step) : this.step; // 每次滚动的步长
      const newScrollTop = Math.max(0, Math.min((this.scrollTop) + (deltaY > 0 ? step : -step), this.CANVAS_HEIGHT - this.VIEWPORT_HEIGHT));
      //  更新视图
      this.setScrollTop(newScrollTop);
      const availableHeight = this.stageRef!.height() - this.PADDING * 2 - this.calculateBarHeight;
      const barY = this.PADDING + (newScrollTop / (this.CANVAS_HEIGHT - this.VIEWPORT_HEIGHT)) * availableHeight;
      //  更新纵向 Rect 滚动条位置
      this.horizontalScrollRef?.y(barY);

      //  滚动方向变化
      if (this.horizontalScrollDirection !== scrollType) {
        this.isInAnimationFrame = false;
        stop?.();
        return;
      }
      //  滚动到底
      const bottom = this.CANVAS_HEIGHT - this.VIEWPORT_HEIGHT === newScrollTop
      if (bottom || newScrollTop === 0) {
        this.isInAnimationFrame = false;
        stop?.();
        return;
      }
    })
    start();
  }
  
import sync, { cancelSync } from "framesync"
export function framesync(update: (delta: number) => void) {
  const passTimestamp = ({ delta }: { delta: number }) => update(delta)

  return {
    start: () => sync.update(passTimestamp, true),
    stop: () => cancelSync.update(passTimestamp),
  }
}

// 计算拖动位置与哪一个 group 相交 const pointer = event.target.getStage()?.getPointerPosition(); (this.LayerRef!.children[1] as any).children.forEach((group: typeGroup, i: number) => {

计算拖动位置与哪一个 group 相交 ,所以在定义画布结构时 一定要有结构 才能正确超找到。 贴一下效果图。

image.png

结束语

写文章的次数不多 有不清楚的多多包涵。 项目已开源: github.com/ayuechuan/T…