🪸

位置やサイズが可変なウィンドウを用意するのであとは自由にレイアウトしてもらいたい

2023/04/16に公開


こうなったらレイアウトは好きなように利用者に決めてもらおう

動機

モバイルファーストでレイアウトしているとポップアップ要素は基本的に動かせないまま真ん中に表示するのでよかった。

しかし、PC ユーザーの一部はポップアップ要素にヘッダーがついていようもんなら普通にマウスで動かそうとするので動かせない UI に不満が出る。これをなんとかしたい。

また WEB 開発は機能よりもレイアウトに時間がかかる。そして考えぬいたレイアウトにしてもすべての人に合うとは限らないので、もう利用者にまかせたい。

コード

App.vue (親)
<template lang="pug">
.App
  button(@click="count += 1") +
  button(@click="count -= 1") -
  template(v-for="i in count" :key="i")
    MyWindow
</template>

<script>
import MyWindow from "./components/MyWindow.vue"

export default {
  components: {
    MyWindow,
  },
  data() {
    return {
      count: 3,
      last_create_id: -1,
      z_index_max: 0,
    }
  },
  provide() {
    return {
      TheApp: this,
    }
  },
}
</script>

<style lang="sass">
:root
  background-color: DeepSkyBlue
</style>
MyWindow.vue (子)
<template lang="pug">
.MyWindow(@mousedown="bring_to_front")
  .head(@mousedown="mousedown_handle" ref="head")
    | {{primary_id}}-{{z_index}}
  .body
    | あのイーハトーヴォのすきとおった風、夏でも底に冷たさをもつ青いそら、うつくしい森で飾られたモリーオ市、郊外のぎらぎらひかる草の波。
    | またそのなかでいっしょになったたくさんのひとたち、ファゼーロとロザーロ、羊飼のミーロや、顔の赤いこどもたち、地主のテーモ、山猫博士のボーガント・デストゥパーゴなど、いまこの暗い巨きな石の建物のなかで考えていると、みんなむかし風のなつかしい青い幻燈のように思われます。では、わたくしはいつかの小さなみだしをつけながら、しずかにあの年のイーハトーヴォの五月から十月までを書きつけましょう。
</template>

<script>
export default {
  data() {
    return {
      primary_id: null,
      z_index: null,
      offset: { x: 0, y: 0 },
    }
  },
  inject: ["TheApp"],
  created() {
    this.TheApp.last_create_id += 1
    this.primary_id = this.TheApp.last_create_id
  },
  mounted() {
    this.bring_to_front()
    this.position_update(64 + this.primary_id * 64, 64 + this.primary_id * 64)
  },
  beforeUnmount() {
    this.TheApp.last_create_id -= 1
  },
  methods: {
    mousedown_handle(event) {
      if (false) {
        this.offset.x = event.offsetX
        this.offset.y = event.offsetY
      } else {
        const rc = this.$el.getBoundingClientRect()
        this.offset.x = event.clientX - rc.left
        this.offset.y = event.clientY - rc.top
      }
      this.$refs.head.style.cursor = "grabbing"
      document.addEventListener("mousemove", this.mousemove_handle)
      document.addEventListener("mouseup", this.mouseup_handle, {once: true})
    },
    mousemove_handle(event) {
      this.$el.style.left = `${event.pageX - this.offset.x}px`
      this.$el.style.top  = `${event.pageY - this.offset.y}px`
    },
    mouseup_handle(event) {
      document.removeEventListener("mousemove", this.mousemove_handle)
      this.$refs.head.style.removeProperty("cursor")
    },
    bring_to_front() {
      this.TheApp.z_index_max += 1
      this.z_index = this.TheApp.z_index_max
      this.$el.style.zIndex = this.z_index
    },
    position_update(x, y) {
      this.$el.style.left = `${x}px`
      this.$el.style.top  = `${y}px`
    },
  },
}
</script>

<style lang="sass">
.MyWindow
  position: fixed
  top: 0px
  left: 0px

  width:  480px
  height: 360px
  overflow: scroll
  resize: both
  border-radius: 4px
  background-color: white
  border: 1px solid LightSkyBlue

  .head
    height: 24px
    background-color: LightSkyBlue
    display: flex
    align-items: center
    justify-content: center
    font-size: 0.75rem
    &:hover
      cursor: grab

  .body
    padding: 1rem
</style>

移動処理の概要

  • ウィンドウを持てる部分はヘッダーなのでそこの @mousedown にひっかける
    • 別の方法として .MyWindow にひっかけて押された要素を判別する方法もある
      • そのメリットはヘッダー以外もトリガーにできること
      • だけどややこしくなるため今回トリガーはヘッダーだけとしておく
  • mousedown が呼ばれたら mousemovemouseup の処理を登録する
    • mousedown と違って mousemovemouseupdocument に結び付ける
  • mousemove イベントが来るたびに動かす
  • mouseup が来たら終わる

イベントプロパティの意味

プロパティ 意味 備考
target イベントが発火した要素(子)
currentTarget イベントを登録した要素(親)
offsetX, offsetY イベントが発火した要素が基点
なし イベントを登録した要素が基点(※)
pageX, pageY コンテンツの左上が基点 ブラウザ内かつ見えてない領域を含む
clientX, clientY ブラウザの左上が基点 ブラウザ内かつ見えている領域のみ
screenX, screenY ディスプレイの左上が基点 ブラウザの外を含む
イベントを登録した要素を基点とする方法
const rc = event.currentTarget.getBoundingClientRect()
const x = event.clientX - rc.left
const y = event.clientY - rc.top

左上が基点にならないように調整する

次の方法だと動くけど左上が基点になってしまう。

NG
mousemove_handle(event) {
  this.$el.style.left = `${event.pageX}px`
  this.$el.style.top  = `${event.pageY}px`
},

次の方法は問題なさげに見えて 1px ずれる。

NG
mousedown_handle(event) {
  this.offset.x = event.offsetX
  this.offset.y = event.offsetY
},
mousemove_handle(event) {
  this.$el.style.left = `${event.pageX - this.offset.x}px`
  this.$el.style.top  = `${event.pageY - this.offset.y}px`
},

理由は offsetX offsetY.MyWindow ではなく、イベントが発火した .head の左上を基点にしたオフセットになっているからで .head の外側にある 1px つまり .MyWindowborder-width の 1px ぶんだけずれる。border-width の値を大きくすると不整合が顕著になる。

次の方法だと .MyWindow を基点としたオフセットになるので問題ない。

Good
mousedown_handle(event) {
  const rc = this.$el.getBoundingClientRect()
  this.offset.x = event.clientX - rc.left
  this.offset.y = event.clientY - rc.top
},
mousemove_handle(event) {
  this.$el.style.left = `${event.pageX - this.offset.x}px`
  this.$el.style.top  = `${event.pageY - this.offset.y}px`
},

もし .MyWindow@mousedown にひっかけて .head がクリックされたのだとすると event.currentTarget.MyWindow を参照しているので、次のように this.$el を置き換えてもよい。

const rc = event.currentTarget.getBoundingClientRect()
this.offset.x = event.clientX - rc.left
this.offset.y = event.clientY - rc.top

が、とくにメリットはないので置き換えなくてもよい。

https://qiita.com/yukiB/items/cc533fbbf3bb8372a924
https://developer.mozilla.org/ja/docs/Web/API/Element/getBoundingClientRect

リサイズ方法

次の魔法のCSSを入れるだけ。

overflow: scroll
resize: both

これで textarea タグのように任意のサイズに変更できるようになる。

https://developer.mozilla.org/ja/docs/Web/CSS/resize

カーソルを変更する

方法1. hover は CSS で書く

.head(ref="head")
mousedown_handle(event) {
  this.$refs.head.style.cursor = "grabbing"
},
mouseup_handle(event) {
  this.$refs.head.style.removeProperty("cursor")
},
.head
  &:hover
    cursor: grab
  • メリット
    • @mouseover @mouseleave が不要になる
    • CSS と分担できる
  • デメリット
    • grabbingthis.$el こと .MyWindow に対してではなく .head の対して設定しないといけなくなるという点で少し複雑になる

方法2. 全部 JavaScript で書く

.head(
  @mouseover="mouseover_handle"
  @mouseleave="mouseleave_handle"
  @mousedown="mousedown_handle"
  )
mouseover_handle(event) {
  this.$el.style.cursor = "grab"
},
mouseleave_handle(event) {
  this.$el.style.removeProperty("cursor")
},
mousedown_handle(event) {
  this.$el.style.cursor = "grabbing"
},
mouseup_handle(event) {
  this.$el.style.removeProperty("cursor")
},
  • メリット
    • JS側で一元管理できて見通しがよくなる
  • デメリット
    • CSSでできることをJSでやってしまっていることへの後ろめたさ

スタイル削除に罠あり

this.$el.style.zIndex を削除したいとき次のように書くと、

NG
this.$el.style.removeProperty("zIndex")

警告もエラーも出さないまま削除されない。これは、

Good
this.$el.style.removeProperty("z-index")

と書くのが正しい。

https://developer.mozilla.org/ja/docs/Web/API/CSSStyleDeclaration/removeProperty

ちなみに this.$el.style.zIndex = null でも消えたが、それでもよかったのかはわからない。

表示優先順位を考慮する

複数のウィンドウがある場合、触った要素は前面に上がってこないといけない。

当初は次のメソッドたちを活用して任意の座標にあるウィンドウを調べていたが無駄に難しい方向に進んでいるような気がしてやめた。

結局、単に (ウィンドウたちのなかの最大の z-index) + 1 をクリックされた .MyWindowz-index に設定するようにした。

.MyWindow(@mousedown="bring_to_front")
bring_to_front() {
  this.TheApp.z_index_max += 1
  this.z_index = this.TheApp.z_index_max
  this.$el.style.zIndex = this.z_index
},

なおコンテンツ領域をクリックしても前面に移動しないといけないので bring_to_front.head ではなく .MyWindow にひっかける。

Vue3 について

当初、Vue3 の練習を兼ねて Composition API の「return でまとめて返さずに <script setup> になってるやつ」[1]で書いていた。しかし、変数にアクセスするたびに .value が必要なのか不要なのかで混乱に陥いった。どうしてこんなことになってしまったのだろうか。

また this がないため親ポインタを子に渡す方法で難航した。getCurrentInstanceで辿るも親で定義したメソッドを見つけらなかった。

結局 Vue3 なのに Options API スタイルで書いた。すると beforeDestroy が呼ばれなくて途方に暮れた。Vue3 になっても Options API スタイルを残しておいてくれている理由は Vue2 の利用者を見捨てず最大限の互換性を維持するためだろうと考えていたが何の警告も出ないまま beforeUnmount にリネームされていた。

脚注
  1. これがなんという名称なのかずっとわからないでいる ↩︎

Discussion