做一个照片墙——可拖动平移和以任意点为中心缩放的DIV组件

原创
06/04 13:28
阅读数 1W

最近在做一个照片墙网页,展示自己画的《明日方舟》的像素画,希望这个网页可以用鼠标拖动平移同时可以以鼠标当前位置为中心滚动滚轮缩放。

上网搜了下,基于 Vue 和 React 的实现很多,但是因为这个网页很简单,所以决定用原生实现。

最终效果可见:https://columns-wings.oss-cn-hangzhou.aliyuncs.com/illusion/

封装

为了复用,我把它封装成一个类,暴露出两个成员,一个容器元素用于挂载到文档,一个内容元素用于添加内容。基础成员包括:

  • $container: 容器元素
  • $content: 内容元素
  • x: 横坐标
  • y: 纵坐标
  • s: 缩放比例

在容器上绑定鼠标移动(mousemove)和鼠标滚轮(mousewheel)事件(暂不考虑浏览器兼容性),在事件处理函数中进行相应计算。

平移

平移可以使用 CSS 的 transform: translate 属性设置,在鼠标 move 事件中读取movementXmovementY来获取鼠标偏移量,添加到元素的位移中:

/**
 * @name 处理鼠标拖动
 * @param {Object} ev 事件对象
 */
handle_move(ev) {
  if (ev.buttons === 1) { // 判断鼠标左键是否按下
    this.x += (ev.movementX / this.s)
    this.y += (ev.movementY / this.s)

    this.translate()
  }
}
/**
 * @name 平移
 */
translate() {
  this.$content.style.transform = `translate(${this.x}px, ${this.y}px)`
}

缩放

缩放可以使用 CSS 的 transform: scale 属性设置:

/**
 * @name 处理鼠标滚轮
 * @param {Object} ev 事件对象
 */
handle_wheel(ev) {
    let delta = -(ev.deltaY / 2000)
    this.s *= 1 + delta

    this.scale()
}
/**
 * @name 缩放
 */
scale() {
  this.$content.style.transform = `scale(${this.s})`
}

缩放中心

缩放中心可以使用 transform-origin 设置,一开始的设想是在滚动滚轮时设置该属性。但是因为 transform-origin 会影响 transform,同时设置时会导致元素的位置突变。

一种处理方式是分析 transform 和 transform-origin 内部的计算方式,然后在外部给 transform: translate 补偿值,即它突变多少就修复多少。但是由于其内部计算方式比较复杂,思考和尝试了很久也没有成功。所以后来决定,自己计算变换矩阵,同时将 transform-origin 设置为 0,即内容元素的左上角。

同时为了简化逻辑和计算,使用两个中间容器来包裹内容元素,一个用于平移,一个用于缩放。这样两种变换就在不同的坐标空间中,不会相互影响。这里将两个元素定义为$translate$scale成员。

矩阵变换

这里以行向量来表示内容元素位置,因为平移变换需要 3 阶矩阵,所以向量的第 3 为设置为 1,其实没有实际意义。

如果以原点为中心缩放,那么只需要将横纵坐标乘以缩放系数就行,矩阵表示为:

其中s为缩放系数。

以任意点为中心缩放,可以直接使用相应的矩阵公式,需要一些计算,也可以进行一个平移变换将元素平移到原点,进行以原点为中心的缩放,再平移模相同但是方向相反的偏移量。这个偏移向量就是当前元素位置的向量,放到平移矩阵中:

其中oxoy是缩放中心坐标。

有了变换矩阵后,将位置向量一次乘以这 3 个矩阵,得到变换后的位置向量。

/**
 * @name 缩放原点
 * @param {Number} delta 缩放系数变化量
 * @param {Number} ox 缩放中心横坐标
 * @param {Number} oy 缩放中心纵坐标
 */
origin(delta, ox, oy) {
  let v = new Matrix(1, 3, [[this.x, this.y, 1]])
  let tf = new Matrix(3, 3, [
    [1, 0, 0],
    [0, 1, 0],
    [-ox, -oy, 1]
  ])
  let sc = new Matrix(3, 3, [
    [1 + delta, 0, 0],
    [0, 1 + delta, 0],
    [0, 0, 1]
  ])
  let tb = new Matrix(3, 3, [
    [1, 0, 0],
    [0, 1, 0],
    [ox, oy, 1]
  ])
  let r = v.multiplyD(tf).multiplyD(sc).multiplyD(tb)

  this.x = r[0][0]
  this.y = r[0][1]
  this.translate()
}

其中Matrix是一个矩阵类,只需要实现点乘方法(multiplyD)即可,具体见代码

这里需要注意的是,设置 transform: translate 使用的是绝对值,但是矩阵变换中的缩放系数是相对量,两者的计算和处理方式不同。

效果如下:

代码

/**
 * @name 矩阵
 */
class Matrix {
  /**
   * @name 构造方法
   * @description 行向量表示。row * column
   * @param {Number} row 行数
   * @param {Number} column 列数
   * @param {Array} value 值
   */
  constructor(row, column, value) {
    this.r = row
    this.c = column

    for (let i = 0; i < row; i++) {
      this[i] = []
    }

    if (value) {
      for (let i = 0; i < this.r; i++) {
        for (let j = 0; j < this.c; j++) {
          this[i][j] = value[i][j] ?? this[i][j]
        }
      }
    }
  }

  /**
   * @name 乘-点乘
   * @param other 矩阵
   * @return 结果
   */
  multiplyD(other) {
    let result = new Matrix(this.r, other.c)
    let n = this.c
    for (let i = 0; i < result.r; i++) {
      for (let j = 0; j < result.c; j++) {
        let value = 0
        for (let k = 0; k < n; k++) {
          value += this[i][k] * other[k][j]
        }
        result[i][j] = value
      }
    }

    return result
  }
}

/**
 * @name 生成可移动、缩放的元素
 */
class Atlas {
  /**
   * @name 构造方法
   * @param {String} width 宽度。CSS
   * @param {String} height 高度。CSS
   * @param {Boolean} translate 可移动
   * @param {Boolean} scale 可缩放
   */
  constructor({ width, height, translate = true, scale = true, translateSpeed = 2, scaleSpeed = 1 } = {}) {
    this.$container = null
    this.$content = null

    this.config = {
      translate: true,
      scale: true,
      translateSpeed: 2,
      scaleSpeed: 1
    }
    this.x = 0
    this.y = 0
    this.s = 1
    this.$translate = null
    this.$scale = null
    this.moveDelta = 0

    let $container = document.createElement('div')
    $container.style.overflow = 'hidden'
    $container.style.position = 'relative'
    $container.style.width = width
    $container.style.height = height
    $container.addEventListener('mousemove', this.handle_move.bind(this))
    $container.addEventListener('click', this.handle_click.bind(this), true)
    $container.addEventListener('mousewheel', this.handle_wheel.bind(this))

    let $translate = document.createElement('div')
    $translate.style.transformOrigin = '0 0'

    let $scale = document.createElement('div')
    $scale.style.transformOrigin = '0 0'

    let $content = document.createElement('div')
    $content.style.width = 'max-content'
    $content.style.height = 'max-content'

    $container.appendChild($translate)
    $translate.appendChild($scale)
    $scale.appendChild($content)

    this.$container = $container
    this.$translate = $translate
    this.$scale = $scale
    this.$content = $content
    this.config.translate = translate
    this.config.scale = scale
    this.config.translateSpeed = translateSpeed
    this.config.scaleSpeed = scaleSpeed
  }

  /**
   * @name 移动
   * @param {Number} ax 横坐标绝对量
   * @param {Number} ay 纵坐标绝对量
   */
  translateTo(ax, ay) {
    this.x = ax ?? this.x
    this.y = ay ?? this.y

    this.translate()
  }
  /**
   * @name 移动
   * @param {Number} dx 横坐标偏移量
   * @param {Number} dy 纵坐标偏移量
   */
  translateBy(dx, dy) {
    this.x += dx ?? 0
    this.y += dy ?? 0

    this.translate()
  }
  /**
   * @name 缩放
   * @param {Number} as 系数绝对量
   */
  scaleTo(as) {
    this.s = as ?? this.s

    this.scale()
  }
  /**
   * @name 缩放
   * @param {Number} ds 系数偏移量
   */
  scaleTo(ds) {
    this.s += ds ?? 0

    this.scale()
  }

  /**
   * @name 处理鼠标拖动
   * @param {Object} ev 事件对象
   */
  handle_move(ev) {
    if (this.config.translate) {
      if (ev.buttons === 1) {
        this.x += (ev.movementX / this.s) * this.config.translateSpeed
        this.y += (ev.movementY / this.s) * this.config.translateSpeed

        this.moveDelta += Math.abs(ev.movementX + ev.movementY)

        this.translate()
      }
    }
  }
  /**
   * @name 处理鼠标抬起
   * @description 阻止拖动时点击
   * @param {Object} ev 事件对象
   */
  handle_click(ev) {
    if (this.moveDelta > 10) {
      ev.preventDefault()
      ev.stopPropagation()
    }

    this.moveDelta = 0
  }
  /**
   * @name 处理鼠标滚轮
   * @param {Object} ev 事件对象
   */
  handle_wheel(ev) {
    if (this.config.scale) {
      let delta = -(ev.deltaY / 2000) * this.config.scaleSpeed

      this.s *= 1 + delta

      this.origin(delta, ev.clientX, ev.clientY)
      this.scale()
    }
  }

  /**
   * @name 平移
   */
  translate() {
    this.$translate.style.transform = `translate(${this.x}px, ${this.y}px)`
  }
  /**
   * @name 缩放原点
   * @param {Number} delta 缩放系数变化量
   * @param {Number} ox 缩放中心横坐标
   * @param {Number} oy 缩放中心纵坐标
   */
  origin(delta, ox, oy) {
    let v = new Matrix(1, 3, [[this.x, this.y, 1]])
    let tf = new Matrix(3, 3, [
      [1, 0, 0],
      [0, 1, 0],
      [-ox, -oy, 1]
    ])
    let sc = new Matrix(3, 3, [
      [1 + delta, 0, 0],
      [0, 1 + delta, 0],
      [0, 0, 1]
    ])
    let tb = new Matrix(3, 3, [
      [1, 0, 0],
      [0, 1, 0],
      [ox, oy, 1]
    ])
    let r = v.multiplyD(tf).multiplyD(sc).multiplyD(tb)

    this.x = r[0][0]
    this.y = r[0][1]
    this.translate()
  }
  /**
   * @name 缩放
   */
  scale() {
    this.$scale.style.transform = `scale(${this.s})`
  }
}

export default Atlas
展开阅读全文
打赏
4
9 收藏
分享
加载中
建议在缩放的时候依然保持拖拽距离跟鼠标移动距离一致,体验会更好哦。
06/07 09:55
回复
举报
氢灵子博主
这点我考虑过,但是这样做在缩放比较大时移动很慢
06/19 16:28
回复
举报
更多评论
打赏
2 评论
9 收藏
4
分享
返回顶部
顶部