0%

基于Dom的无限画布实现方案

基于Dom的无限画布实现方案

针对无限画布这个场景,在业界figma的画布交互体验是公认的比较好的。不过figma采用的是canvas方案。本文介绍基于Dom的无限画布实现方案。

image-20240507100547446

主体结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!-- 画布容器 -->
<InfiniteCanvas>
<!-- 渲染器 -->
<Renders>
<!-- 布局器 在合适的位置渲染元素渲染器 -->
<Layouter>
<!-- 元素渲染器 -->
<ItemRender/>
</Layouter>
</Renders>
<!-- 设计器 -->
<Designer>
<!-- 已选中元素的指示器 -->
<SelectedBox/>
<!-- 布局提示线 -->
<LayoutHintLine/>
<!-- 右键菜单 -->
<ContentMenu/>
<!-- 刷选框 -->
<SelectBox/>
<!-- 拖拽添加元素时的蒙板 -->
<DragOverMask/>
<Designer/>
<!-- 控制器 -->
<Controller/>
</InfiniteCanvas>

核心属性

1
2
3
4
5
6
7
8
9
10
11
// 画布的移动属性 
canvasTransform: {
// 缩放比例
scale: 1,
// 画布由于平移引起的 偏移量
translateX: 0,
translateY: 0,
// 画布由于缩放引起的 偏移量
canvasOffsetX: 0,
canvasOffsetY: 0,
}

布局方式

根据画布的移动属性计算画布内对应元素的位置和缩放比例,根据计算结果绘制各元素。

Q: 为什么计算画布内对应元素的位置而不是计算画布的位置对画布进行整体平移?

A: 画布的宽高需要固定为100%。一是为了方便挂载具体事件处理方法。二是为了方便计算新元素进来的时候新元素的布局信息。

布局算法

平移

image-20240514104127663

1
2
3
// 平移 直接修改偏移量
translateX += dx
translateY += dy

缩放

image-20240514113918078

1
2
3
4
5
6
7
8
9
10
11
12
// 根据缩放中心缩放
// 1. 计算原始比例下的 缩放中心至起始点的距离 = 当前缩放中心位置 - 当前由于缩放引起的画布偏移量 / 当前缩放比例
// 2. 计算由于缩放比例变化引起的画布偏移量 = 原始比例下的缩放中心至起始点的距离 * 缩放比例变化量
// 3. 计算下一个缩放比例下的 由于缩放引起的画布偏移量 = 当前由于缩放引起的画布偏移量 + 由于缩放比例变化引起的画布偏移量
originDx = (transformOriginX - canvasOffsetX) / scale;
originDy = (transformOriginY - canvasOffsetY) / scale;

dx = originDx * deltaScale;
dy = originDy * deltaScale;

canvasOffsetX += dx;
canvasOffsetY += dy;

布局

1
2
x = itemLeft * scale + canvasOffsetX + translateX;
y = itemTop * scale + canvasOffsetY + translateY;

自适应画布

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 自适应画布 
// 目的为:使画布中所有的元素正好填充满画布 既不溢出 也不浪费画布空间
// 操作方式为:修改 canvasOffsetX canvasOffsetY scale 使布局的元素大小和位置正合适
// 记画布宽高为 width height 画布中所有元素所组成的矩形为 itemCollection

// 当画布中所有元素所组成的矩形 小于画布大小时 直接将其缩放比例设为1 平移至左上角
if (itemCollectionWidth < width && itemCollectionHeight < height) {
scale = 1;
canvasOffsetX = -itemCollectionLeft
canvasOffsetY = -itemCollectionTop
} else {
const scaleX = width / (itemCollectionWidth);
const scaleY = height / (itemCollectionHeight);
// 判断是横向缩放还是纵向缩放
if (scaleX < scaleY) {
// 横向缩放
scale = scaleX;
// 横向缩放-横向画布偏移量
canvasOffsetX = -itemCollectionLeft * scaleX;
// 横向缩放-纵向画布偏移量
canvasOffsetY =
(height - itemCollectionHeight * scaleX) / 2 -
itemCollectionTop * scaleX;
} else {
// 纵向缩放
scale = scaleY;
// 纵向缩放-横向画布偏移量
canvasOffsetX =
(width - itemCollectionWidth * scaleY) / 2 -
itemCollectionLeft * scaleY;
// 纵向缩放-纵向画布偏移量
canvasOffsetY = -itemCollectionTop * scaleY
}
}
}

各模块功能

InfiniteCanvas

提供画布容器。

Renders

渲染器 为画布中的各元素提供容器

1
2
3
4
5
6
7
8
9
10
11
<template>
<Layouter
v-for="(item, index) in items"
:canvasTransform="canvasTransform"
:key="`${item.uuid}`"
:layout="item.layout"
:index="index"
>
<slot name="ItemRender"></slot>
</Layouter>
</template>
Layouter

布局器 根据布局信息 渲染容器

1
2
3
4
5
6
7
<template>
<div
:style="layout(layout, canvasTransform)"
>
<slot></slot>
</div>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
* 根据画布的状态 重新计算坐标
*
* @param {*} layout
* @param {*} canvasTransform
*/
export const changeLayoutByCanvasTransform = function (
layout,
canvasTransform
) {
const { x, y } = layout;
const { translateX, translateY, scale, canvasOffsetX, canvasOffsetY } =
canvasTransform;
const left = x * scale + canvasOffsetX + translateX;
const top = y * scale + canvasOffsetY + translateY;
return {
left,
top,
};
};

layout(layout, canvasTransform) {
if (!layout) return {};
const { left, top } = changeLayoutByCanvasTransform(
layout,
canvasTransform
);
const style = {
left: left + "px",
top: top + "px",
transform: `scale(${canvasTransform.scale})`,
position: "absolute",
"z-index": layout.z,
};
return style;
},
ItemRender
1
2
3
<template>
<item />
</template>
Designer

设计器为画布的交互和事件提供容器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<template>
<SelectedBox
:canvasTransform="canvasTransform"
:xgChartsPackage="xgChartsPackage"
/>
<DragOverMask
:width="config.initChartWidth"
:height="config.initChartHeight"
:showDragOverMask="showDragOverMask"
:dragOnPosition="dragOnPosition"
:canvasTransform="canvasTransform"
/>
<LayoutHintLine
:vLine="vLine"
:hLine="hLine"
:canvasTransform="canvasTransform"
/>
<ContentMenu
v-show="contextMenuVisible"
:actionList="contentMenuActionList"
:contextMenuPosition="contextMenuPosition"
/>
<SelectBox :show="showSelectBox" :position="selectBoxPosition" />
</template>
SelectedBox

选中元素的提示框

image-20240515112321213

1
2
3
4
5
6
7
8
<template>
<div
:style="selectBoxStyle"
>
<div :class="SELECT_BORDER"></div>
<div :class="RESIZE_ICON"></div>
</div>
</template>
LayoutHintLine

拖拽时 元素的对齐指示线和吸附

image-20240515112753472

SelectBox

刷选元素时的范围提示框

image-20240515112935007

DragOverMask

新增元素时的提示框

image-20240515113019579

Controller

控制器

image-20240515113102778

ContentMenu

右键菜单