首先进行功能分析和 dom 结构分析
- 采用了 canvas 渲染 ( 如果不是 canvas 渲染 那就没必要去实现... ),多画布结构,一个layer用于静态数据展示,另外一个 layer 用于频繁更新渲染的用户操作。
- 画册卡片的宽度是一个区间值,并非固定 暂定 250 <= width <=325,它会随着可视区域的宽度动态调整。
- 卡片数量超过整体的高度超过画布可视区域会出现滚动条,这个滚动条也是用 canvas实现的,那我们也就不要用 div 来模拟。
- 可以拖动某个卡片到其他行列卡片进行排序,存在滚动条的情况下,拖动到顶部 | 底部边界还会自动滚动。
接下来我们一一的实现这些功能。
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 相交 ,所以在定义画布结构时 一定要有结构 才能正确超找到。 贴一下效果图。
结束语
写文章的次数不多 有不清楚的多多包涵。 项目已开源: github.com/ayuechuan/T…