画像プレビューをCSSのtransformで実装しようとしたけど諦めた話

書きながらいろいろ情報が整理され、ただ変な方向に向かっていって苦しんでいただけだったことがわかっただけのポエムになってしまった気がする。

## つくろうと思ったもの
* Vue.jsで実装
* よくある、画像プレビュー
* 拡大、縮小、回転、ドラッグができる
* ドラッグしたとき、プレビュー画像が見切れないように制御する

これを、`transform: scale(1) rotate(0deg) translate(0,0);`みたいな感じで制御しようとした。

## なぜ諦めたか
### rotateすると、translateの方向も回転する
`transform: scale(1) rotate(0deg) translate(0,0);`だけで完結したかったが、、
rotateで回転している時に、translateで移動させようとすると、思った方向に移動しなかった。
例えば、180度回転させた状態でドラッグすると、思った方向と真逆に移動する・・・
これは、厳密に言うと、解決できないことはない。と思う。。
ただ、解決方法は、ドラッグの移動先を回転に合わせて全て調整するということ。
今回つくろうとしていた回転の仕様は90度刻みの4パターンだったので、まだ可能な範囲だったが、コードが増えるだけだと思い、泣く泣く違う方法（`position: absolute;`で制御）で対応することにした。

また、ドラッグがない場合は特に問題なくtransformで完結できる。

## 最終的につくったもの
* scaleとrotateはtransformで制御
* ドラッグはpositionで制御

```vuejs
<template>
  <div ref="drag_area" class="drag_area" @mousedown="dragStart($event)" @mousemove="dragMove($event)" @mouseup="dragEnd($event)" @mouseleave="dragEnd($event)">
    <img ref="preview_image" class="preview_image" src="image.png" :style="{transform: 'scale('+scale+') rotate('+rotate+'deg)',top: new_pos.y+'px',left: new_pos.x+'px'}">
    <div class="control_btn">
      <button @click="zoomIn"></button>
      <button @click="zoomOut"></button>
      <button @click="rotateLeft"></button>
      <button @click="rotateRight"></button>
      <button @click="resetStyle"></button>
    </div>
  </div>
</template>

<script>
export default {
  data(){
    return{
      scale: 0.8,
      rotate: 0,
      new_pos : {
        x : 0,
        y : 0,
      },
      prev_pos : {
        x : 0,
        y : 0,
      },
      isDrag : false,
    }
  },
  mounted(){
    this.resetStyle();
  },
  methods: {
    zoomIn(){
      this.scale *= 1.2;
      if(this.scale > 10){
        this.scale = 10;
      }
    },
    zoomOut(){
      this.scale /= 1.2;
      if(this.scale < 0.1){
        this.scale = 0.1;
      }
    },
    rotateLeft(){
      this.rotate -= 90;
    },
    rotateRight(){
      this.rotate += 90;
    },
    resetStyle(){
      this.scale = 0.8;
      this.rotate = 0;
      this.new_pos.x = (this.$refs.drag_area.offsetWidth-this.$refs.preview_image.offsetWidth)/2;
      this.new_pos.y = (this.$refs.drag_area.offsetHeight-this.$refs.preview_image.offsetHeight)/2;
      this.prev_pos = {
        x : 0,
        y : 0,
      };
    },

    dragStart(e){
      e.preventDefault();
      this.isDrag = true;
      this.prev_pos.x = e.pageX;
      this.prev_pos.y = e.pageY;
    },
    dragMove(e){
      if(this.isDrag){
        // マウス座標の差分
        let moved_x = e.pageX-this.prev_pos.x;
        let moved_y = e.pageY-this.prev_pos.y;

        // ドラッグで移動できる範囲を制御
        let a_w = this.$refs.drag_area.offsetWidth;
        let a_h = this.$refs.drag_area.offsetHeight;
        let b_w = this.$refs.preview_image.offsetWidth;
        let b_h = this.$refs.preview_image.offsetHeight;
        let c_w = b_w*this.scale;
        let c_h = b_h*this.scale;
        let diff_w = (c_w-c_h)/2;
        let diff_h = (c_h-c_w)/2;

        let max_x = a_w-(b_w-c_w)/2-40;
        let min_x = -(b_w-(b_w-c_w)/2-40);
        let max_y = a_h-(b_h-c_h)/2-40;
        let min_y = -(b_h-(b_h-c_h)/2-40);
        // 回転したときの差分
        if(this.rotate/90%2 !== 0){
          max_x -= diff_w;
          min_x += diff_w;
          max_y -= diff_h;
          min_y += diff_h;
        }

        // 移動距離を反映
        let new_pos_x = this.new_pos.x+moved_x;
        let new_pos_y = this.new_pos.y+moved_y;
        if(new_pos_x > max_x){
          this.new_pos.x = max_x;
        }else if(new_pos_x < min_x){
          this.new_pos.x = min_x;
        }else{
          this.new_pos.x = new_pos_x;
        }
        if(new_pos_y > max_y){
          this.new_pos.y = max_y;
        }else if(new_pos_y < min_y){
          this.new_pos.y = min_y;
        }else{
          this.new_pos.y = new_pos_y;
        }

        // マウス座標を更新
        this.prev_pos.x = e.pageX;
        this.prev_pos.y = e.pageY;
      }
    },
    dragEnd(e){
      this.isDrag = false;
    },
  }
}
</script>

<style lang="scss" scoped>
// ※デザインの部分は省略してます。
.drag_area{
  cursor: move;
  position: relative;
  overflow: hidden;
  .preview_image{
    position: absolute;
    top: 0;
    left: 0;
    width: auto;
    height: auto;
    max-width: 100%;
    max-height: 100%;
    transform: scale(.8) rotate(0deg);
  }
}
</style>
```

### 少し解説
#### インラインスタイルをバインディング
* `transform: scale(.8) rotate(0deg);` `top: 0;` `left: 0;`の値を更新する。

```html
<img ref="preview_image" class="preview_image" src="images.png" :style="{transform: 'scale('+scale+') rotate('+rotate+'deg)',top: new_pos.y+'px',left: new_pos.x+'px'}">
```

#### `@mousedown="dragStart($event)"`でドラッグ開始
* 今回、画像プレビューだったので、drag_areaにドラッグイベントをセットしているが、対象のみにイベントをつける場合はpreview_imageにしても大丈夫はず。
* isDragがtrueのときだけマウスの動きに合わせてプレビュー画像を移動する。
* `offsetX`,`offsetY`を使ったら、誤作動したので、`pageX`,`pageY`を使用して、ドラッグを開始した位置を保存。

```js
dragStart(e){
  e.preventDefault();
  this.isDrag = true;
  this.prev_pos.x = e.pageX;
  this.prev_pos.y = e.pageY;
},
```

#### `@mousemove="dragMove($event)"`でプレビュー画像を移動する
##### 最低限の部分
* dragStart()で保存した開始位置と今のマウス座標の差分（=ドラッグした距離）を今のtopとleftの値に加算して移動させる。

```js
dragMove(e){
  if(this.isDrag){
    // マウス座標の差分
    let moved_x = e.pageX-this.prev_pos.x;
    let moved_y = e.pageY-this.prev_pos.y;

    // 移動距離を反映
    this.new_pos.x += moved_x;
    this.new_pos.y += moved_y;
 
    // マウス座標を更新
    this.prev_pos.x = e.pageX;
    this.prev_pos.y = e.pageY;
  }
},
```

##### 「ドラッグしたとき、プレビュー画像が見切れないように制御する」の部分
いろいろな要因から複雑に考えてしまっていたが、結構シンプルな計算だった。
一応画像で説明します。
また、`-40`は見切れないように残す大きさなので、説明では無視します。

A:ドラッグできるエリア（drag_area）
B:プレビュー画像のデフォルトサイズ（preview_imageのscale(1)）
C:プレビュー画像の表示サイズ（preview_imageのscale(0.8)）
とします。
![preview_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34634/8eb7fd42-a6ae-0aa0-d471-52eba1cea62e.png)

ここでの問題は、**支点が、scaleしても変わらない**ということ
具体的に言うと、Cの支点もBの左上の位置ということです。

Cの右に移動できる範囲（max_x）は、
scaleが1だったら、
Aの横幅

scaleが1ではなかったら、
Aの横幅 - BとCの横幅の差分の半分
![preview_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34634/00cf5642-1104-bf16-bb85-3e79f393dfb7.png)

Cの左に移動できる範囲（min_x）は、
Bの横幅 - BとCの横幅の差分の半分
![preview_3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34634/4dae010c-e8bd-b6a2-b51e-c34dfeec5564.png)
となります。

```js
// ドラッグで移動できる範囲を制御
let a_w = this.$refs.drag_area.offsetWidth;
let a_h = this.$refs.drag_area.offsetHeight;
let b_w = this.$refs.preview_image.offsetWidth;
let b_h = this.$refs.preview_image.offsetHeight;
let c_w = b_w*this.scale;
let c_h = b_h*this.scale;

let max_x = a_w-(b_w-c_w)/2;
let min_x = -(b_w-(b_w-c_w)/2);
let max_y = a_h-(b_h-c_h)/2;
let min_y = -(b_h-(b_h-c_h)/2);
``` 

あとは、このmax,minの値でドラッグの移動距離を制御する。

```js
// 移動距離を反映
let new_pos_x = this.new_pos.x+moved_x;
let new_pos_y = this.new_pos.y+moved_y;
if(new_pos_x > max_x){
  this.new_pos.x = max_x;
}else if(new_pos_x < min_x){
  this.new_pos.x = min_x;
}else{
  this.new_pos.x = new_pos_x;
}
if(new_pos_y > max_y){
  this.new_pos.y = max_y;
}else if(new_pos_y < min_y){
  this.new_pos.y = min_y;
}else{
  this.new_pos.y = new_pos_y;
}
```

##### 回転したときの調整
これも、諦めかけた問題、、
**rotateしても、支点の位置は変わらない**

どういうことかというと、
例えば、横長の画像を90度回転させて、縦長にしたとする。
しかし、支点は、回転する前の位置なので、それを考慮してドラッグ可能範囲を設定しなければならない。
![preview_4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34634/3a9eb738-6f09-994c-6694-236297b19230.png)
しかしこれは、冷静に考えれば、今回の90度刻みの仕様なら対応できると気づき、
縦向きになったとき（rotateを90で割り、奇数かどうかで判断）に
Cの縦横の差分の半分を足すか引くかで調整できた。

```js
let diff_w = (c_w-c_h)/2;
let diff_h = (c_h-c_w)/2;

// 回転したときの差分
if(this.rotate/90%2 != 0){
  max_x -= diff_w;
  min_x += diff_w;
  max_y -= diff_h;
  min_y += diff_h;
}
```

#### `@mouseup="dragEnd($event)"` `@mouseleave="dragEnd($event)"`でドラッグ終了
```js
dragEnd(e){
  this.isDrag = false;
},
```

### 上記の理由で結局使わないことになったが、rotateが絡まなければ解決した、scaleとtranslateの兼ね合い
* 私をややこしくした原因は、scaleの支点をセンターで行いたい関係で、translateの支点も左上ではなくセンターだったことである。
* ドラッグ可能範囲の計算をとても複雑に考えてしまった。。
* scaleの値はtranslateにも影響があり、なかなかうまく調整できなかったが、scaleの値を割って計算することで、辻褄を合わせることができた。
* `transform: scale(1) translate(0,0);`なら、問題なく調整できた。

差分のみ書きます。

* `transform: scale(.8) translate(0,0);`をインラインスタイルにバインディング

```html
<img ref="preview_file" class="preview_file" :src="images[activeIndex].data" :style="{transform: 'scale('+scale+') translate('+new_pos.x+'px,'+new_pos.y+'px)'}">
```

#### ドラッグ可能範囲の計算

↓なぞに考えてしまった内容
支点がセンターなので、
Cの右に移動できる範囲（max_x）は、
scaleが1かつ、AとBの横幅が同じだったら、
Cの横幅

scaleが0.8で、AとBの横幅が同じだったら、
(Cの横幅 + BとCの横幅の差分の半分) ÷ 0.8

scaleが0.8で、AとBの横幅が違ったら、
(Cの横幅 + BとCの横幅の差分の半分 + AとBの横幅の差分) ÷ 0.8

このように、scaleの値を割ることによって、扱うべき値をもとめることができる。

と、なぞに複雑に考えていたが、（本当はさらにからみ合ってこれと同じ値を導き出していた）
パズルをはめ直してみると、
(Aの横幅 - BとCの横幅の差分の半分) ÷ 0.8
で問題なかった。。

ようは、scale分割って戻せばよかったのだった。。。

```js
let max_x = (a_w-(b_w-c_w)/2)/this.scale;
let min_x = -(b_w-(b_w-c_w)/2)/this.scale;
let max_y = (a_h-(b_h-c_h)/2)/this.scale;
let min_y = -(b_h-(b_h-c_h)/2)/this.scale;
```

お疲れ様でした。

## 参考サイト
https://fuwafuwac.com/?p=748
https://qiita.com/yukiB/items/cc533fbbf3bb8372a924