ことの発端
先日、息子からの夏休みの宿題として個人でアイロンビーズエディタ(IBE)というのを開発しました。
今回はその時に実装しようとした選択範囲のUI/UX(↓こんなの)を再現する話です。
実物を触って見てもらった方が良いと思いますので、こちらに用意しています。
開発秘話的なものはnoteに記載しているので省略しますが、一言で言えばペイントツールにありがちな「選択範囲」と「コピペ」を実装しようと思ったからです。
エクセルやAdobeのphotoshop的なUI/UXにしたい。
言うまでもありませんが、後発のツールのUI/UXは他のツールからの影響を多大に受けます。
簡単なもので言うと、多くのアプリケーションがCtrl(Command)+ZでUndoを実装していたり、Ctrl+Cでコピーできるのは当たり前の世界になっています。
そのスタンダードなUI/UXの1つに「選択範囲」を示すものがあります。
MicrosoftのExcelやAdobeのPhotoshopで点線の枠がアニメーションする定番(?)のものです。
で、これをIBEに実装しようと思ったわけですね。
そして、いつものように「JSを書きたくないので、なんとかCSSとJSでどうにかできないかなぁ?」と試行錯誤したわけですが…
hover? active? focus? 使えるのあるっけ?
ということで、なんとなくマウスがDom要素の上にきた時のcssの擬似セレクタを想像&調べてみますが、「hoverが一番近い気がするがマウスが離れると無理だな」ということが頭をよぎり無理そうです。
予想通り、私の実力ではJSと一緒に実装しないと無理でした。
とりあえずJSの「mousedown」「mouseup」「mouseover」でやるしかないなと諦めます。
アニメーションしなきゃ楽勝だったのに…
で、とりあえず「点線」をシミュレーションするんですが、考えられるのは
border:1px dotted #000;
とか
outline:1px dashed #000;
とかですが、outlineはtopだけとかができないので、borderで実装しようとしたんですが、一応確認ということでPhotoshopを立ち上げてみました。
点滅じゃなく右上から左下に流れるようになっている…
↑(記事公開時には時計回りと表現していましたが、指摘をいただき確認したところ時計回りじゃなく「右上から左下へ」が正解でした&以下のCSSも指摘をもらって変更しています。)
アニメーションを見てげんなりです。
「これは、borderじゃぁ無理くさい、よくあるHack技のbackground-imageとグラデ+animationの複合技を使うしかないのかぁ」と覚悟を決めます。
ということで、一旦こんな感じのcssを書きます。
.test_border{
background-image :linear-gradient(to left, #000 4px, #fff 4px), linear-gradient(to bottom, #000 4px, #fff 4px), linear-gradient(to right, #000 4px, #fff 4px), linear-gradient(to bottom, #000 4px, #fff 4px);
background-size:8px 1px, 1px 8px, 8px 1px, 1px 8px;
background-repeat:repeat-x, repeat-y, repeat-x, repeat-y;
background-position:left top, right, bottom, left;
animation:ani-test-border 1s infinite linear;
}
@keyframes ani-test-border{
0% {
background-position:top left,top right ,left bottom ,top left ;
}
100% {
background-position:right top, right bottom,right bottom, left bottom;
}
}
で、ここで気づくわけです。
「あれ?これって上だけパターンと下だけパターンのを複数classを持つことできないよな…」と。
<span class="ani-border-top ani-border-bottom"></span>
↑こんなのが使えないわけです。
そうなると「これ、パターン多すぎじゃね?」というのが頭をよぎります。
ですが、もうしょうがないので一旦整理していきます。
全部で15パターン
rotateなどを使わないとなると1+3+3+8の15パターンのCSSを作らないといけません。
「これは、CSSの命名規則をプログラムに合わせていかないと面倒だなぁ…」
ということで、なんとなくborderのCSSの見本にして時計回りにTop、Right、Bottom、Leftの頭文字を「trbl」の順にしてそれを配列として持ちます。
const css_pos = ["t","r","b","l"]
選択したように見せかける
とりあえず、mouseoverかclickされたところにselected的(optionタグと混同しそうなのでselectingとします)なclassを付加します。
let is_mouse_down = false
div.addEventListener("mousedown", event =>{
is_mouse_down = true
if (!div.classList.contains("selecting")){
div.classList.add("selecting")
}
})
div.addEventListener("mouseup", event =>{
is_mouse_down = false
})
div.addEventListener("mouseover", event =>{
if (is_mouse_down){
if (!div.classList.contains("selecting")){
div.classList.add("selecting")
}
}
})
あと、debug面倒なので、Ctrl+Dで選択範囲解除とかその辺もやりたいのでデフォルトの動きを無効化しておきます。
(この時はCtrl+Shift+Dで選択範囲を反転するのも実装しようとしていました。)
document.body.addEventListener('keydown', event =>{
if (event.ctrlKey && event.key == "c"){
event.preventDefault()
}
if (event.ctrlKey && event.key == "v"){
event.preventDefault()
}
if (event.ctrlKey && event.key == "x"){
event.preventDefault()
}
if (event.ctrlKey && event.key == "d"){
event.preventDefault()
//ここに選択解除のfunctionを呼び出す
}
})
あとは愚直にチェック!
あとは、最上段、最下段、右端、左端に注意しながら、選択された上下左右のマスが選択されているかのチェックをして、選択部分の一番上なら0、右端なら1、下ならば2、左端なら3と配列にpushしていきます。
const css_list = []
selecting_list.forEach((is_selecting, index) => {
const position_index = []
//選択してない時は空のリスト
if (is_selecting == 0){
css_list.push(position_index)
return
}
if (index < (rowMax-1)){
position_index.push(0)
}else{
//任意の位置より上が選択されてない時
if (selecting_list[index-rowMax] !== 1){
position_index.push(0)
}
}
//略
css_list.push(position_index)
}
この辺り脳死で書いてるので良いアルゴリズムあると思います。
最後は配列の数でcssを追加
const css_list = checkSelectingList()
const css_prefix = "sb_"
const css_pos = ["t","r","b","l"]
main.querySelectorAll("div").forEach((div,index) => {
const indexes = css_list[index].sort()
if (!div.classList.contains("selecting")){
return
}
//面倒なので一旦全部削除
div.removeAttribute("class")
div.classList.add("selecting")
//選択範囲だけど線が必要ない時
if (indexes.length == 0){
return
}
let css_text = css_prefix
for (index of indexes){
css_text += css_pos[index]
}
div.classList.add(css_text)
})
このぐらいになると「これ使わないかもしれないなぁ…」(後述)という感情になってやっつけで実装していました。
結局ボツネタ
ここまで、自分になりに愚痴を言いながら実装したので小一時間ぐらい(40分程度)かかってしまったんですが
- 複数タブを使ってデータをやりとりするとclipboardでのやりとりが発生し実装が微妙になりそうだなぁ
- 横幅が10マスのものの9マス目に10マスのものをペーストした時に描画領域を拡張するのか?
- こんなマニアックな用途なものにそこまで需要ないだろ…
などと思い実装を取りやめました。
(複数タブを気にしなければCtrl+CをトリガーにしてselectringのついたDOMを操作すればいいので簡単だったんですが…)
あと、これが正方形や長方形のテンプレートなら良いのですが丸型のテンプレートとか六角形のテンプレートになると、それぞれの列数が変わるので実装考えるのが面倒になったのも大きな理由です。
点線が時計回りのアニメーションと勘違いしなければ、ここまで遠回りをする必要もなかった気がしますが、以上古くからあるUI/UXを勘違いして再現に四苦八苦した話でした。
↑指摘をいただき勘違いしてたので「勘違いを追加」