位置やサイズが可変なウィンドウを用意するのであとは自由にレイアウトしてもらいたい
こうなったらレイアウトは好きなように利用者に決めてもらおう
動機
モバイルファーストでレイアウトしているとポップアップ要素は基本的に動かせないまま真ん中に表示するのでよかった。
しかし、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
が呼ばれたらmousemove
とmouseup
の処理を登録する-
mousedown
と違ってmousemove
とmouseup
はdocument
に結び付ける
-
-
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
左上が基点にならないように調整する
次の方法だと動くけど左上が基点になってしまう。
mousemove_handle(event) {
this.$el.style.left = `${event.pageX}px`
this.$el.style.top = `${event.pageY}px`
},
次の方法は問題なさげに見えて 1px ずれる。
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 つまり .MyWindow
の border-width
の 1px ぶんだけずれる。border-width
の値を大きくすると不整合が顕著になる。
次の方法だと .MyWindow
を基点としたオフセットになるので問題ない。
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
が、とくにメリットはないので置き換えなくてもよい。
リサイズ方法
次の魔法のCSSを入れるだけ。
overflow: scroll
resize: both
これで textarea タグのように任意のサイズに変更できるようになる。
カーソルを変更する
方法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 と分担できる
-
- デメリット
-
grabbing
はthis.$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
を削除したいとき次のように書くと、
this.$el.style.removeProperty("zIndex")
警告もエラーも出さないまま削除されない。これは、
this.$el.style.removeProperty("z-index")
と書くのが正しい。
ちなみに this.$el.style.zIndex = null
でも消えたが、それでもよかったのかはわからない。
表示優先順位を考慮する
複数のウィンドウがある場合、触った要素は前面に上がってこないといけない。
当初は次のメソッドたちを活用して任意の座標にあるウィンドウを調べていたが無駄に難しい方向に進んでいるような気がしてやめた。
- https://developer.mozilla.org/ja/docs/Web/API/Document/elementFromPoint
- https://developer.mozilla.org/ja/docs/Web/API/Document/elementsFromPoint
- https://developer.mozilla.org/ja/docs/Web/API/Document/caretPositionFromPoint
結局、単に (ウィンドウたちのなかの最大の z-index) + 1
をクリックされた .MyWindow
の z-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 にリネームされていた。
-
これがなんという名称なのかずっとわからないでいる ↩︎
Discussion