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

首先进行功能分析和 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'StageHeight' | 'StageWidth' | 'autofit'>

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

  const { stageManager } = useAlbumPaintingStore()
  const stageRef = useRef(null)
  const layerRef = useRef(null)
  const verticalBarRef = useRef(null)
  const horizontalBarRef = useRef(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 (
    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)}
    >
      onMouseDown={stageManager.handleMouseDown.bind(stageManager)}
        ref={layerRef}>
        selector=".controls-layer">
          visible={stageManager.portalGroupVisible}>
            width={2}
              height={stageManager.rowHeight - 10}
              fill="#003cab"
              visible={stageManager.highlightLineVisible}
              draggable={true}
            />
            width={stageManager.draggerRect?.width || 0}
              height={stageManager.draggerRect?.height || 0}
            >
              {createElement(ActiveDragElement, stageManager.draggerRect as any)}
            
          
          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))
              }
            }}
          />
          {/* 横向滚动条 */}
          {/* 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
              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()
              }
            }}
          /> */}
        
        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}`,
            })
          })}
        
      
      name="controls-layer"
        height={stageManager.CANVAS_HEIGHT}
        width={600}
        visible={true}
      />
    
  )
})

画册卡片的宽度
 calculateOptimalCardCount(
    canvasWidth: number,
    minCardWidth: number,
    maxCardWidth: number,
    margin = 10
  ) {
    let optimalCount = 0
    let optimalWidth = 0

    // 从最小宽度到最大宽度逐步计算
    for (let width = minCardWidth
      // 每个卡片的宽度加上边距
      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); 
    return height;
  };

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

 render() {
    const { finalWidth, optimalCount } = this;
    const starttime = performance.now();
    
    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[] = [];
    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>;
  }

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

    
    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)) {
        
        if (group.attrs.id === this.groupRef?.target!.attrs.id) {
          this.dragingHighlightLine = { x: 0, y: 0 };
          return;
        }

        
        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 })
  }
  
  0}
   height={stageManager.draggerRect?.height || 0}
    >
     {createElement(ActiveDragElement, stageManager.draggerRect as any)}
   
  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;
      
      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 相交 ,所以在定义画布结构时 一定要有结构 才能正确超找到。
贴一下效果图。

image.png

结束语

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

阅读全文
下载说明:
1、本站所有资源均从互联网上收集整理而来,仅供学习交流之用,因此不包含技术服务请大家谅解!
2、本站不提供任何实质性的付费和支付资源,所有需要积分下载的资源均为网站运营赞助费用或者线下劳务费用!
3、本站所有资源仅用于学习及研究使用,您必须在下载后的24小时内删除所下载资源,切勿用于商业用途,否则由此引发的法律纠纷及连带责任本站和发布者概不承担!
4、本站站内提供的所有可下载资源,本站保证未做任何负面改动(不包含修复bug和完善功能等正面优化或二次开发),但本站不保证资源的准确性、安全性和完整性,用户下载后自行斟酌,我们以交流学习为目的,并不是所有的源码都100%无错或无bug!如有链接无法下载、失效或广告,请联系客服处理!
5、本站资源除标明原创外均来自网络整理,版权归原作者或本站特约原创作者所有,如侵犯到您的合法权益,请立即告知本站,本站将及时予与删除并致以最深的歉意!
6、如果您也有好的资源或教程,您可以投稿发布,成功分享后有站币奖励和额外收入!
7、如果您喜欢该资源,请支持官方正版资源,以得到更好的正版服务!
8、请您认真阅读上述内容,注册本站用户或下载本站资源即您同意上述内容!
原文链接:https://www.shuli.cc/?p=21141,转载请注明出处。
0

评论0

显示验证码
没有账号?注册  忘记密码?