LoginSignup
17
12

HTML+CSSでPhotoshop風選択範囲のアニメーションを再現しようとしたら凄く面倒くさかった

Last updated at Posted at 2023-08-30

ことの発端

先日、息子からの夏休みの宿題として個人でアイロンビーズエディタ(IBE)というのを開発しました。

今回はその時に実装しようとした選択範囲のUI/UX(↓こんなの)を再現する話です。

スクリーンショット 2023-08-30 10.25.50.png

実物を触って見てもらった方が良いと思いますので、こちらに用意しています。

開発秘話的なものは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パターン

  • 1マスだけ選択した時(上下左右の1パターン)
    スクリーンショット 2023-08-30 10.37.20.png

  • 3マス横に選択した時(右側は上下右、真ん中は上下、左側は上下左の3パターン)
    スクリーンショット 2023-08-30 10.38.46.png
    スクリーンショット 2023-08-30 10.38.22.png
    スクリーンショット 2023-08-30 10.39.00.png

  • 3マス縦に選択した時(上は上左右、真ん中は左右、下は下左右の3パターン)
    スクリーンショット 2023-08-30 10.39.14.png
    スクリーンショット 2023-08-30 10.44.27.png
    スクリーンショット 2023-08-30 10.39.26.png

  • 縦横3マスずつを選択した時(左上は上と左、上の真ん中は上のみ、右上は上と右、真ん中の左は左のみ、真ん中の右は右のみ、左下は左と下、下の真ん中は下のみ、右の下は右と下の8パターン)
    スクリーンショット 2023-08-30 10.40.07.png
    スクリーンショット 2023-08-30 10.37.44.png
    スクリーンショット 2023-08-30 10.40.17.png
    スクリーンショット 2023-08-30 10.47.44.png
    スクリーンショット 2023-08-30 10.47.35.png
    スクリーンショット 2023-08-30 10.39.57.png
    スクリーンショット 2023-08-30 10.37.53.png
    スクリーンショット 2023-08-30 10.39.48.png

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を勘違いして再現に四苦八苦した話でした。

↑指摘をいただき勘違いしてたので「勘違いを追加」

17
12
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
12