作一個照片牆——可拖動平移和以任意點爲中心縮放的DIV組件

最近在作一個照片牆網頁,展現本身畫的明日方舟的像素畫,但願這個網頁能夠用鼠標拖動平移同時能夠以鼠標當前位置爲中心滾動滾輪縮放。瀏覽器

上網搜了下,基於 Vue 和 React 的實現不少,可是由於這個網頁很簡單,因此決定用原生實現。markdown

最終效果可見:columns-wings.oss-cn-hangzhou.aliyuncs.com/illusion/app

封裝

爲了複用,我把它封裝成一個類,暴露出兩個成員,一個容器元素用於掛載到文檔,一個內容元素用於添加內容。基礎成員包括:函數

  • $container: 容器元素
  • $content: 內容元素
  • x: 橫座標
  • y: 縱座標
  • s: 縮放比例

在容器上綁定鼠標移動(mousemove)和鼠標滾輪(mousewheel)事件(暫不考慮瀏覽器兼容性),在事件處理函數中進行相應計算。oop

平移

平移可使用 CSS 的 transform: translate 屬性設置,在鼠標 move 事件中讀取movementXmovementY來獲取鼠標偏移量,添加到元素的位移中:ui

/** * @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 屬性設置:this

/** * @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})`
}
複製代碼

這裏spa

縮放中心

縮放中心可使用 transform-origin 設置,一開始的設想是在滾動滾輪時設置該屬性。可是由於 transform-origin 會影響 transform,同時設置時會致使元素的位置突變。3d

一種處理方式是分析 transform 和 transform-origin 內部的計算方式,而後在外部給 transform: translate 補償值,即它突變多少就修復多少。可是因爲其內部計算方式比較複雜,思考和嘗試了好久也沒有成功。因此後來決定,本身計算變換矩陣,同時將 transform-origin 設置爲 0,即內容元素的左上角。code

同時爲了簡化邏輯和計算,使用兩個中間容器來包裹內容元素,一個用於平移,一個用於縮放。這樣兩種變換就在不一樣的座標空間中,不會相互影響。這裏將兩個元素定義爲$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
複製代碼
相關文章
相關標籤/搜索