Web 画图技术之画布滚动方案

1,012 阅读13分钟

主流的画板的滚动有两种交互,暂且把他们命名为:无限画布和有限画布

无限画布:

Excalidraw 、BlockSuite 算是无限画布,言外之意时画布是没有区域限制的,可以无限滚动到任何区域,也就是因为无限滚动的原因,整个画布是没法显示确切的滚动条,用户也就无法明确的知道那个区域有绘图元素。

有限画布:

语雀、XMind、ProcessOn 等产品是采用有限画布的形态,有限画布有一个优点就是可以显示滚动条 - 能直观的展示看出内容区域的大小,并且滚动区域会跟随绘图元素及其位置而动态更新。

我们最终选择了「有限画布」的交互形态。

画布大小

画布的大小及坐标体系遵循一个基础逻辑:

  1. 保证所有绘图元素可见
  2. 上下左右要留出一定的内边距(一般容器宽高的 1/2)

画布的显示区域是根据元素的位置及大小决定的,也就是要通过设置 SVG 的 viewBox 控制元素显示的坐标系保证元素内容可见,然后通过设置 SVG 元素宽度和容器宽高差来形成滚动条,这个滚动条是浏览器的默认真实滚动条。

viewBox 属性的值是一个包含 4 个参数的列表 min-x , min-y , width and height ,以空格或者逗号分隔开,在用户空间中指定一个矩形区域映射到给定的元素 developer.mozilla.org/zh-CN/docs/…

当画布无缩放的情况下 SVG 的元素宽高和 SVG 的 viewBox 的宽高是一致的,SVG 的 viewBox 的值有元素内容的位置及大小决定,基础的计算规则如下:

min-x

所有元素内容的最小 x 坐标 - 容器宽度/2

min-y

所有元素内容的最小 y 坐标 - 容器高度/2

width

所有元素内容的最大 x 坐标 - 所有元素内容的最小 x 坐标 + 容器宽度

height

所有元素内容的最大 y 坐标 - 所有元素内容的最小 y 坐标 + 容器高度

通过这个计算规则可以轻松得出:无论绘图元素有元素多少、位置如何、大小如何他们都能显示在 viewBox 设定的坐标系内,并且元素的绘图元素上下左右边界距离视口都有一个固定的滚动距离。

缩放支持

画布的缩放是通过改变 SVG 元素的宽高实现,就是保持 viewBox 映射区域计算逻辑不变,让真实 SVG DOM 的宽高放大/或者指定比例实现绘图元素的缩放。

逻辑关系:

  1. SVG 元素宽度 = viewBox 宽度 * zoom
  2. SVG 元素宽度 = viewBox 高度 * zoom

viewBox 计算逻辑:

前面已经对 viewBox 映射区域的计算规则进行了描述,但那是基础的逻辑,没有考虑缩放情况,实际计算中为了保证缩放前后绘图元素上下左右边界距离视口的滚动距离是固定,需要容器宽高以及初始坐标也进行等比缩放,最终的计算规则变成:

min-x

所有元素内容的最小 x 坐标 - 容器宽度 / 2 / zoom

min-y

所有元素内容的最小 y 坐标 - 容器高度 / 2 / zoom

width

所有元素内容的最大 x 坐标 - 所有元素内容的最小 x 坐标 + 容器宽度 / zoom

height

所有元素内容的最大 y 坐标 - 所有元素内容的最小 y 坐标 + 容器高度 / zoom

基本约束:

  1. 元素内容永远在在滚动区域内容,元素内容周围一定为预留一定的滚动区域(左右是画布容器宽度的一半,上下是画板容器高度的一半)。
  2. 元素内容空间占位不足(元素空间占位的宽高小于画板容器宽高)时需要补位,保证最小滚动距离是画布容器的距离。
  3. 元素内容空间占位越大滚动条可滚动区域越大。

下图示意说明:

  1. 红色区域是绘图元素占位区域(不足时需要补位,至少占满画板容器窗口)
  2. 底层深蓝色的是画板容器窗口,用户的可视区域
  3. 最底下浅蓝色是 SVG 元素,撑开画板容器,出现滚动条

绘图元素占位与预留滚动区域示意:

元素较少元素占位补位示意:

绘图元素增多时 SVG 更大滚动区域也变大:

viewBox 计算逻辑(简化版本):

github.com/worktile/pl…

export function calcNewViewBox(board: PlaitBoard, zoom: number) {
    const viewportContainerRect = PlaitBoard.getBoardNativeElement(board).getBoundingClientRect();
    const elementHostBBox = getElementHostBBox(board, zoom);

    const horizontalPadding = viewportContainerRect.width / 2;
    const verticalPadding = viewportContainerRect.height / 2;
    const viewBox = [
        elementHostBBox.left - horizontalPadding / zoom,
        elementHostBBox.top - verticalPadding / zoom,
        elementHostBBox.right - elementHostBBox.left + (horizontalPadding * 2) / zoom,
        elementHostBBox.bottom - elementHostBBox.top + (verticalPadding * 2) / zoom
    ];
    return viewBox;
}

SVG 元素属性设置:

github.com/worktile/pl…

export function setSVGViewBox(board: PlaitBoard, viewBox: number[]) {
    const zoom = board.viewport.zoom;
    const hostElement = PlaitBoard.getHost(board);
    hostElement.style.display = 'block';
    hostElement.style.width = `${viewBox[2] * zoom}px`;
    hostElement.style.height = `${viewBox[3] * zoom}px`;

    if (viewBox && viewBox[2] > 0 && viewBox[3] > 0) {
        hostElement.setAttribute('viewBox', viewBox.join(' '));
    }
}

视口位置

视口位置代表一种状态,用视口位置记录用户在视口内的滚动偏移量及缩放比,当用户刷新浏览器后可以基于存储的视口位置恢复画布的状态,对应的类型是 ViewPort:

export interface Viewport {
    zoom: number;
    origination?: Point;
}

其中 orignation 不是特别容易理解,需要绕一下,首先它代表的表层业务意义就是滚动偏移量,当画布滚动后它的值会变,但是它实际的值又不是滚动距离, origination 的实际值代表的是画板容器左上角的位置点在 SVG viewBox 映射坐标系内的位置,这里将其定义为视口坐标 ,相同 zoom、相同 origination、相同绘图元素的情况下容器元素距离左上角的距离是固定,由此实现对视口位置的定位控制。

下图是 origination 与画板容器、viewBox 映射坐标系的位置关系:

Origination 位置示意

可以理解为 Element A 和 origination 是在同一坐标体系内(viewBox 映射的坐标系),他们的相对位置是固定的,然后 origination 永远指向视口(画板容器)的左上角,这样就很容易得出 Element A 在视口内的位置是可预期的。

还有一点需要明确: 将视口坐标 origination 的值定位到视口的左上角需要通过设定画板的滚动距离实现 ,也就是将画板滚动到指定位置,它们之间的转换逻辑下面介绍。

视口偏移与滚动距离

视口偏移和滚动距离其实对应的是两个坐标系,视口偏移对应的是 viewBox 映射坐标系,而滚动距离对应的是浏览器的屏幕坐标系,而缩放比为 1 的情况下视口偏移与滚动距离其实是 1:1 的关系。

视口偏移距离计算逻辑实例如下图所示:

视口偏移与滚动距离

offsetX:origination.x - viewBoxStart.x

offsetY:origination.y - viewBoxStart.y

当时缩放比不为 1 时视口偏移距离乘以缩放比后和滚动距离才是 1:1 的对应关系。

最终,视口偏移到滚动距离的转换关系如下:

scrollLeft:(origination.x - viewBoxStart.x) * zoom

scrollLeft:(origination.y - viewBoxStart.y) * zoom

代码位置:

github.com/worktile/pl…

export function updateViewportOffset(board: PlaitBoard) {
    const origination = getViewportOrigination(board);
    if (!origination) {
        return;
    }
    const [scrollLeft, scrollTop] = toHostPointFromViewBoxPoint(board, origination);
    updateViewportContainerScroll(board, scrollLeft, scrollTop);
}

视口偏移重新计算

影响视口偏移的情况有三种:

  1. 画板滚动
  2. 画板缩放

以上两种情况发生,视口的 origination 需要重新计算,他们的逻辑也不复杂,下面一一解释。

画板滚动

这个前面介绍「视口偏移与滚动距离」时已经介绍,他们之间的转换逻辑也已经详细说明,只不过前面介绍的是:视口偏移 -> 滚动距离,而这里要说明的其实是它的反过程,就是用户滚动画板后调整对应的视口偏移也就是 origination,因为转换逻辑的思路是同一个,所以这里不多做介绍。

画板缩放

画板缩放时的处理稍微复杂一点,而它也是这个滚动方案的唯一难点。

画板缩放后 SVG 元素的宽高会发生变化,这代表着屏幕坐标系和 viewBox 坐标系的对应关系被打乱,因此视口偏移和滚动距离的映射需要重新计算,并且画板缩放会改变视口偏移量(origination),计算逻辑需要选定一个中心点,基于中心点的位置重新推算新的 origination,它的计算逻辑如下:

// 选定的中心点,以当前窗口中心作为中心点
let focusPoint = [boardContainerRect.width / 2, boardContainerRect.height / 2];
// 当前缩放比
const zoom = board.viewport.zoom;
// 当前 orination
const origination = getViewportOrigination(board);
// 当前中心点
const centerX = origination![0] + focusPoint[0] / zoom;
const centerY = origination![1] + focusPoint[1] / zoom;
// 新的 origination
const newOrigination = [centerX - focusPoint[0] / newZoom, centerY - focusPoint[1] / newZoom] as Point;

新 origiantion 计算的原则是确保缩放前后选中中心点的位置相对视口不发生偏移,这个点位原本如果是在中心,那么缩放该改点位还是在中心。

某些场景下缩放的中心点不是浏览器窗口的中心点,有可能是选定鼠标所在位置作为中心点,这时只需要把 focusPoint 调整为鼠标点所在的位置即可(鼠标点位对应到画板容器的屏幕坐标系的位置)。

元素数据变化

绘图元素数据变化虽然不会影响视口偏移,但是因为元素变化会导致 viewBox 坐标系发生变化,原本从视口偏移映射的滚动距离将不再准确,所以当绘图元素数据变化后需要重新映射滚动距离,这个逻辑被封装在 with-viewport 中。

边界情况: 

当删除元素时,因为 viewBox 映射坐标系会跟随着变化,这是可能存在的两种边界情况: 

1. 当前的 origination 坐标失效,这个主要是由于 viewBox 映射坐标系改变后无法保证 origination 还在新的 viewBox 映射坐标系内,或者是由 origination 所带来的屏幕区域对应坐标还在新的 viewBox 映射坐标系内,这时需要根据 当前的滚动条位置修正 origination

2. 新映射的滚动距离未发生变化,这个主要针对滚动条滚动到最左边或者最右边的场景,数据变化映射新的滚动距离时发生滚动距离并未真正的发生改变,因而不会触发滚动事件,也就无法进行上面「边界情况 1」中提到的「修正 origination」

处理代码: github.com/worktile/pl…

处理流程

① 初始化流程:

② 视口变化处理流程:

③ 滚动处理流程:

当用户操作滚动视图窗口时其实就是涉及 viewport 视口的偏移量发生变化,所以在浏览器滚动发生后就需要根据最新的滚动距离反向重新推算视口偏移量,然后更新视口偏移量(调用 updateViewport )

打破循环:

  1. 在「 ③ 滚动处理流程 」中会监控滚动事件,然后计算新的 origiantion 进而调用 updateViewport 方法,updateViewport 其实就是更改 board 的 viewport 属性,这个操作会触发 onChange 事件,也就是会进入「 ② 视口变化处理流程 」。
  2. ② 视口变化处理流程 」最终会根据最新的 viewport 数据推算浏览器的滚动偏移量,然后更新 viewport 容器的滚动距离,这个操作其实会额外触发滚动事件,因此会进入「 ② 视口变化处理流程

可以看出这个两个流程是存在循环调用风险的,稍有一点点的计算偏差都会造成死循环,所以在代码上进行了中断处理:

  1. ③ 滚动处理流程 」中调用 updateViewport 会增加一个 IS_FROM_SCROLLING 标识,在执行「 ② 视口变化处理流程 」时发现如果更新来源于 IS_FROM_SCROLLING 则不再更新 viewport 滚动的滚动距离操作。
  2. ③ 滚动处理流程 」中调用会判断基于最新计算的滚动偏移量 newOrigination 和 当前的 origiantion 是否相同,如果相同则不再调用 updateViewport ,打破中断(理论上这里也是增加一个标识更稳妥)。

关于几个坐标系

前面介绍的内容都是基于逻辑,这里简单总结下,前面一共出现了三种坐标系:

  1. 屏幕坐标系([0,0] 位置是屏幕左上角)
  2. 基于 Host 的屏幕坐标系([0,0] 位置是 Host 元素左上角,也及时 SVG 元素左上角)
  3. SVG viewBox 映射坐标系

我们通过 pointer 或者 mouse 事件拿到的 x,y 是基于 ① 屏幕坐标系的,我们绘图元素 points 属性存储的坐标是基于 ③ viewBox 映射坐标系的,而 ② 是一个中间状态的坐标系,① 和 ② 可以认为都是屏幕坐标系,但是他们参考的起点不同、数值与视口的缩放比无关,③ -> ① 和 ① -> ③ 的转换都需要考虑缩放比。

附录一:坐标转换

① 屏幕坐标 -> Host 屏幕坐标

这个转换的由来是基于 DOM 的 pointerdown、pointermove、pointerup 等事件源中获取到的坐标(event.x, event.y)是以浏览器左上角为起始点([0, 0]),而 Plait 画板视口 Host 屏幕坐标系是以 SVG 左上角的屏幕坐标为起始点([0, 0]),某些场景下画板也不是占满整个屏幕,即使视口不发生滚动,屏幕坐标和视口屏幕坐标也不一定相等:

// x,y 为鼠标事件源坐标
export function toHostPoint(board: PlaitBoard, x: number, y: number): Point {
    const host = PlaitBoard.getHost(board);
    const rect = host.getBoundingClientRect();
    return [x - rect.x, y - rect.y];
}

Host 坐标也是基于屏幕坐标系,但是它的起始位置是从 SVG 元素的左上角的屏幕坐标: 1. 当滚动距离为 0 时,它的起始坐标等于画布滚动容器的屏幕坐标; 1. 当画板占满整个浏览器窗口的情况下,它的起始坐标等于画板滚动容器的水平和垂直的滚动偏移量,视口水平和滚动距离为 0 时,视口屏幕坐标等于屏幕坐标;

② Host 屏幕坐标 -> viewBox 映射坐标

Host 屏幕坐标和画板 SVG 通过 viewBox 映射的坐标系的坐标有一定的对应关系,需要考虑缩放比的影响,转换逻辑也不复杂:

export function toViewBoxPoint(board: PlaitBoard, hostPoint: Point) {
    const viewBox = getViewBox(board);
    const { zoom } = board.viewport;
    const x = hostPoint[0] / zoom + viewBox.x;
    const y = hostPoint[1] / zoom + viewBox.y;
    const newPoint = [x, y] as Point;
    return newPoint;
}

以视口水平坐标值 x 为例,计算思路是:x 坐标➗缩放比 + viewBox 起始 x 坐标,它是基于缩放的技术实现逻辑反推的(参考:【缩放支持】->【逻辑关系】)。

③ viewBox 映射坐标 -> Host 屏幕坐标

与过程 ② 互为反过程,参见 plait 中 toHostPointFromViewBoxPoint 的实现。

④ Host 屏幕坐标 -> 屏幕坐标

与过程 ① 互为反过程,参见 plait 中 toScreenPointFromHostPoint 的实现。

附录二:滚动条隐藏

当画板作为一个组件内嵌到页面或者其他场景时,可能希望隐藏画板的滚动条,比如我们 Wiki 页面内插入画板插件的场景就是如此,这是一直显示滚动条就有些丑了。

技术实现思路

这块的技术实现是增加一个真正的「滚动容器」DOM 元素(class='viewport-container'),滚动容器是有滚动条的,但是外层画板容器的 DOM 元素(class='plait-board-container')会把里面的滚动容器元素的滚动条盖住。

隐藏滚动条:

  1. 红色区域画板容器:用户可视区域
  2. 红色设置超出隐藏
  3. 紫色区域滚动条部分被遮盖

不显示滚动条时:

**显示滚动条时:
**

结束!

绘图框架 Plait:github.com/worktile/pl…