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