JS实现鼠标漫游

1,104 阅读7分钟

什么是鼠标漫游

日常使用思维导图、关系图、流程图的时候,你是否留意过他们操作画布的过程。鼠标漫游就是通过移动光标和滚轮,完成画布缩放、移动的交互过程。

2022-12-09 15.29.38.gif

实现 svg 内鼠标漫游

svg 绘图使用原点在左上角的坐标系统,一个单位代表一像素。这里的像素不能简单理解为屏幕像素,是一个用户单位。svg 的 widthheight 属性决定图像在用户系统的占位。viewBox 属性则决定占位视口映射到 svg 图像范围的映射。

<svg xmlns="http://www.w3.org/2000/svg" width="width" height="height" viewBox="x y width height"></svg>

image.png

调整 viewBox 即可完成图像内容的缩放和移动:

  • x 增加,视口向右移动,图像内容看起来向左移动
  • y 增加,视口向下移动,图像内容看起来向上移动
  • viewWidth 和 viewHeight 增加,视口变大,图像内容看起来缩小

1. 滚轮缩放

鼠标 wheel 事件可以监听到鼠标滚轮的转动。事件对象 deltaY 属性可以获取到模拟的滚动距离(这与用户系统设置的鼠标滚轮方向、速度有关)。

很容易想到像下面这段伪代码一样处理缩放:

获取 svg『尺寸』
svg.onWheel:
    if deltaY > 0
        『尺寸』增大
    if deltaY < 0
        『尺寸』缩小
    更新 svg 视图

首先,需要获取 svg 的尺寸。为了避免图像宽高比例影响缩放的步长,这里用对角线来衡量 svg 尺寸。

试想缩放 10 * 30 和 30 * 10 两张 svg 图时,缩小原图高的 10%(currentheight - 0.01 * height),变换步长分别为 0.3 和 0.1 。为抹平图片长宽比例带来的步长差异,以对角线衡量尺寸

const svg = document.querySelector<SVGSVGElement>('svg');
if(!svg) {
    return;
}
const width = Number(svg.getAttribute('width'));
const height = Number(svg.getAttribute('height'));
// 全图的对角线长
const diagonal = Math.sqrt(width * width + height * height);
const [x, y, viewWidth, viewHeight] = svg.getAttribute('viewBox')
    .split(' ')
    .map(item => Number(item));
// 当前 viewBox 对角线长
let currentDiagonal = Math.sqrt(viewWidth * viewWidth + viewHeight * viewHeight);

接着,监听 wheel 事件,处理对角线的增减。不希望用户过度的缩放,这里限制缩放范围在 [0.1, 5] 的区间内。

svg.addEventListener('wheel', e => {
  e.preventDefault();
  if (e.deltaY > 0) {
    if (currentDiagonal / diagonal >= 5) {
      return;
    }
    currentDiagonal = currentDiagonal + 0.01 * diagonal;
  }
  else if (e.deltaY < 0) {
    if (currentDiagonal / diagonal <= .1) {
      return;
    }
    currentDiagonal = currentDiagonal - 0.01 * diagonal;
  }
  // todo: 更新 svg 视图
});

最后,如何将新的对角线转化为 viewBox 属性的 x、y、viewWidth、viewHeight,才能保证鼠标 hover 的内容不随缩放偏移呢?

image.png

先以水平方向为例,竖直方向同理。

  • 为保证缩放前后图像任意一点与缩放中心 *(鼠标位置)的距离随比例变化, offset 的比例变化与 width 的比例变化相等,即①式。
  • newX+newOffsetX=offsetXnewX + newOffsetX = offsetXoldX+oldOffsetX=offsetXoldX + oldOffsetX = offsetX。由于鼠标到视口的距离可分为鼠标到图像边缘和图像边缘到视口两部分,则有②式。
  • 两等式都包含 oldOffsetX - newOffsetX ,将①式代入②式,得到等式如下:
newX=oldX+offsetX×oldWidthnewWidthwidthnewX = oldX + offsetX \times \frac{oldWidth - newWidth}{width}

newWidth 和 oldWidth 可以通过图像比例和对角线计算,oldX、offsetX 和 width 都已知。计算出 newX 和 newWidth 后,竖直方向 newY 和 newHeight 也同理可得。

const {offsetX, offsetY} = e;
const [strX, strY, strW, strH] = svg.getAttribute('viewBox')?.split(' ') ?? [];
const w = currentDiagonal * width / diagonal;
const h = currentDiagonal * height / diagonal;
const x = Number(strX) + offsetX * (Number(strW) - w) / width;
const y = Number(strY) + offsetY * (Number(strH) - h) / height;
svg.setAttribute('viewBox', `${x} ${y} ${w} ${h}`);

效果还不错哦,wheel 事件甚至模拟了滚轮的惯性。

2. 鼠标拖拽

mousedown、mousemove、mouseup 三个鼠标事件组合是常见的拖拽处理思路。比如下面这段伪代码:

定义『拖拽起点』
svg.onmousedown:
    更新『拖拽起点』
window.onmouseup:
    『拖拽起点』置空
svg.onmousemove:
    if !『拖拽起点』

首先,需要定义拖拽起点。这里注意,因为拖拽过程中需要基于起点的视口状态更新视口,我们需要记录拖拽开始时 viewBox 的值。

let draggingContext: {
  point: [number, number];
  viewBox: string;
}|null = null;

接着,监听 mousedown、mouseup、mousemove 事件。由于需要处理用户拖拽到视口外的情况,mouseup 事件的监听对象是 window 。

svg.addEventListener('mousedown', e => {
  draggingContext = {
    point: [e.offsetX, e.offsetY],
    viewBox: svg.getAttribute('viewBox') as string,
  };
});
window.addEventListener('mouseup', () => {
  draggingContext = null;
});
svg.addEventListener('mousemove', e => {
  if (!draggingContext) {
    return;
  }
  // todo: 计算新的 x 和 y ,更新 svg 视图
});

最后,计算新的 x 和 y ,更新 svg 视图。

image.png

同样先以水平方向为例,竖直方向同理。

  • 由于拖拽是在固定缩放比例的情况下移动,拖拽前后的鼠标的移动距离需要由屏幕像素换算为虚拟像素,即①式。
  • 新的图像到视口的距离由旧的图像到视口距离和移动距离组成,即②式。
  • 将①式代入②式,可得:
newX=oldX+(newOffsetXoldOffsetX)×oldWidthwidthnewX = oldX + (newOffsetX - oldOffsetX) \times \frac{oldWidth}{width}

注意 newX=oldX+(newOffsetXoldOffsetX)newX = oldX + (newOffsetX - oldOffsetX) 是不对的!

拖拽是在固定缩放比例的情况下移动,需要将屏幕像素转换到 svg 的虚拟像素。试想一下,在地球仪上轻轻拨动手指,对应现实世界的位移可是差之千里。

newOffsetX 和 oldOffsetX 可以从鼠标事件对象获取,oldX、oldWidth 和 width 都已知。newWidth 与 oldWidth 相等,计算出 newX 后,竖直方向 newY 和 newHeight 也同理可得。

const offset = [e.offsetX - draggingContext.point[0], e.offsetY - draggingContext.point[1]];
const [strX, strY, strW, strH] = draggingContext.viewBox.split(' ');
const realOffet = [
  offset[0] * Number(strW) / width,
  offset[1] * Number(strH) / height,
];
const x = Number(strX) - realOffet[0];
const y = Number(strY) - realOffet[1];
svg.setAttribute('viewBox', `${x} ${y} ${strW} ${strH}`);

拖拽的效果还不错,emmm,似乎还有一些小 bug 。

3. 处理边界情况

您应该发现了,同时进行拖拽和缩放时,图像会在不同大小『闪动』。

前面我们在 mousedown 时更新了用于参照的起始点 (draggingContext) 。这导致鼠标按下到抬起的这段时间内都是以开始的比例计算位移,虽然中间缩放了。

那么在缩放后更新参照点 (draggingContext) 就好了。

let draggingContext: {
  point: [number, number];
  viewBox: string;
}|null = null;
svg.addEventListener('wheel', e => {
  // 缩放计算逻辑...
  
  if (draggingContext) {
    draggingContext = {
      point: [e.offsetX, e.offsetY],
      viewBox: `${x} ${y} ${w} ${h}`,
    };
  }
});

完整示例

扩展一下:我们是否能将漫游效果应用于普通图片缩放呢?

  • wheel、mousedown、mousemove、mouseup 对普通 DOM 也适用
  • svg 可以使用 viewBox 完成缩放、位移
  • DOM 可以使用样式 scale() translate() 完成缩放、位移

肯定可以!

扩展到其他场景

基础的计算逻辑在 svg 场景中已经实现。目前需要将这些逻辑与 svg 解耦,再扩展到更多场景。用 ES6 class 实现这种扩展是个好主意。

abstract class Roam {}
class SvgRoam extends Roam {}
class DomRoam extends Roam {}
// ...

1. 重构:抽象类 Roam

  • transform: scale(s) translate(x, y)
  • viewBox='x y width height'

观察以上两种记录图像变换的方式,最小的信息集是 scalexy。内容的宽高是可以根据视口宽高、scale 计算出的。

interface ViewInfo {
    scale: number;
    x: number;
    y: number;
}

抽象类 Roam 的结构应该包括:

  • el:当前视口元素
  • draggingContext:拖拽参考点的信息
  • currentScale:当前的缩放比例
  • scaleHandler() :处理缩放的方法
  • dragHandler() :处理拖拽的方法
  • init() :用于初始化视口的方法【要求子类实现】
  • ......
abstract class Roam {
  constructor(el: HTMLElement|SVGSVGElement) {
    this.el = el;
    this.scaleHandler();
    this.dragHandler();
  }
  abstract init(): void;
  el: HTMLElement|SVGSVGElement;
  draggingContext: null|(ViewInfo&{
    point: [number, number];
  }) = null;
  currentScale = 1;
  scaleHandler() {
    // todo: 缩放
  }
  dragHandler() {
    // todo: 拖拽
  }
}

首先,实现 scaleHandler 缩放逻辑。

abstract class Roam {
  // others
  // 防止子元素冒泡导致的 offsetX 参照对象变化
  client2offset(clientX: number, clientY: number) {
    const { x, y } = this.el.getBoundingClientRect();
      return {
        offsetX: clientX - x,
        offsetY: clientY - y,
    };
  }
  scaleHandler(step = 0.01, minSide = .1, maxSide = 5) {
    this.el.addEventListener('wheel', e => {
      e.preventDefault();
      const event = e as WheelEvent;
      if (event.deltaY < 0) {
        if (this.currentScale >= maxSide) {
          return;
        }
        this.currentScale = this.currentScale + step;
      }
      else if (event.deltaY > 0) {
        if (this.currentScale <= minSide) {
          return;
        }
        this.currentScale = this.currentScale - step;
      }
      const { offsetX, offsetY } = this.client2offset(event.clientX, event.clientY);
      const { x: oldX, y: oldY, scale: oldScale } = this.getViewInfo();
      const x = oldX + offsetX * (oldScale - this.currentScale);
      const y = oldY + offsetY * (oldScale - this.currentScale);
      const viewInfo = {
        x,
        y,
        scale: this.currentScale,
      };
      this.setViewInfo(viewInfo);
      if (this.draggingContext) {
        this.draggingContext = {
          point: [offsetX, offsetY],
          ...viewInfo,
        };
      }
    });
  }
  
  // 与应用场景交互
  abstract getViewInfo(): ViewInfo;
  abstract setViewInfo(params: ViewInfo): void;
}

计算逻辑与svg的缩放一致,将涉及的前后宽高比转换为 scale 。由于要与场景 API 解耦,原来的读写 viewBox ,抽象为了 getViewInfosetViewInfo,由场景实现。

注意 scale=virtualWidthviewWidthscale = \frac{virtualWidth}{viewWidth} ,比如 width="100" height="100" viewBox="0 0 200 200" 对应 scale = 2,视图为缩小效果

由于 DOM 场景子元素冒泡将导致事件对象 offsetX 属性参照子元素,通过 clientX - getBoundingClientRect().x 间接获取鼠标到视口元素(el)的距离。

然后,实现 dragHandler 拖拽逻辑。

abstract class Roam {
  // others
  dragHandler() {
    this.el.addEventListener('mousedown', e => {
      const { offsetX, offsetY } = this.client2offset(e.clientX, e.clientY);
      this.draggingContext = {
        point: [offsetX, offsetY],
        ...this.getViewInfo(),
      };
    });
    window.addEventListener('mouseup', () => {
      this.draggingContext = null;
    });
    this.el.addEventListener('mousemove', e => {
      if (!this.draggingContext) {
        return;
      }
      const { offsetX, offsetY } = this.client2offset(e.clientX, e.clientY);
      const offset = [offsetX - this.draggingContext.point[0], offsetY - this.draggingContext.point[1]];
      const { x: oldX, y: oldY, scale: oldScale } = this.draggingContext;
      const realOffet = [
        offset[0] * Number(oldScale),
        offset[1] * Number(oldScale),
      ];
      this.setViewInfo({
        x: oldX - realOffet[0],
        y: oldY - realOffet[1],
        scale: this.currentScale,
      });
    });
  }
}

计算逻辑与 svg 的拖拽一致,同样将涉及的前后宽高比转换为 scale注意 offsetX 通过 clientX 间接获取。

2. 扩展 SvgRoam

有了抽象类 Roam 扩展到子类 SvgRoam 就很容易了。结合 svg 读写 viewBox 的方式实现 Roam 要求提供的方法。

3. 扩展 DomRoam

实现 DOM 场景时与 svg 有一些不同:

  • viewBox 是操作视口范围,而 transform 是操作图像内容。比如想让图像放大,svg viewBox 的宽高要缩小,而 transform scale 要增加。同理,位移也是反向的。
  getViewInfo() {
    const { transform = '' } = this.content?.style ?? {};
    const [_, strScale, strX, strY] = transform.match(/scale((.+)) translate((.+)px, (.+)px)/) || [];
    return {
      x: -Number(strX),
      y: -Number(strY),
      scale: 1 / Number(strScale),
    };
  }
  • img 标签有默认的拖拽行为,需要添加 draggable="false" ,禁用此行为。
  • 为模拟 svg 左上角为原点的坐标系统, transformOrigin 变换中心需要设置在左上角
  • 为了使 translate 可以和图像的视觉宽高参照同一『用户单位』,CSS 变换需要先缩放后位移

当代列文虎克直呼内行!

思考

以上我们实现了 svg 和 DOM 两个场景下的鼠标漫游效果,是否能用 Roam类扩展 canvas 场景的效果呢?DOM 场景下多子元素的鼠标漫游是否能实现呢?欢迎评论区讨论。