NISHIO Hirokazu[Translate]
2021-07-20Movidea開発日記

---
マウスイベント周りの問題、前回Regroupの時はかなり複雑になってから自覚したのでしっかり理解することができなかったが、今回は理解できた

人間にとって好ましい、自然な、「意味の塊」は左のようなもの
グループのドラッグでの移動
範囲選択
選択範囲のドラッグでの移動

しかし現実にはブラウザの仕様によって
ドラッグでの移動のdropイベントはどちらの場合も共通
どの操作をしててもmousemoveやmouseupは呼び出される(追記: この解釈がそもそも間違いなのを後述)

Regroupの時はすべてCanvas要素の上で操作されることもあって全部mousedown開始だった
バツ印は「mousedown後、mousemoveが発生せずに、mouseupが発生した時に行う処理」の意味
要するにクリック

そこでこうした
ハンドラーオブジェクトに「一塊の処理」をまとめて、mousedownのタイミングでどのハンドラーを使うかを決定

一見良さそうに見えるがダメ
複数のハンドラで共通して行わなければいけない処理がある
それぞれに書いたら漏れが出る
この問題を解決するために「もう一枚レイヤーを挟んで共通で行う処理をまとめて行う」という設計変更が行われた
mousedownの段階で処理が確定することを暗黙に仮定していた
そうでないケースが扱いにくくなってしまった
mousedownが来た直後にもう一つmousedownが来てマルチタッチになったら?
「付箋をグループにドロップすることでグループの中に入れたい」はドロップ時の状況による場合分け

それを踏まえて今回のこの状況をどう整理すればいいか
わかった気がしたがまたわからなくなった…
暗黙に状態が発生しているから、それを陽に管理したらいいのでは、と思ったのだった
現状のコードではmousedownでisDraggingフラグを立ててる
いや、ダメだな、現状の実装で既に「僕の理解」で書いた右の図と実際の振る舞いが違う
mouseDown, mouseMove, dragStart, dropの順で実行されてしまう
その結果フラグが立ちっぱなし
実際の挙動
今回の場合、上の処理の開始位置にある要素はDOM的に下の処理の要素に包含されてるので、mousedownを上にもつけて、そちらでつかんでstopPropagateする手がある
っていうかそれ以外の手がなさそう
要するに、ソースコードにコード上の包含関係に基づくレキシカルスコープがあるのと同様に、DOMにもその包含関係に基づくスコープがあるわけだ
そう考えるとmousedownでフラグを立てて、フラグが立ってる時だけmousemoveで特定の処理をするのは動的スコープで時間軸上の区間を切り取ってるのだな
それを図で表現するとこうなる
これを踏まえて今回必要なことを整理するとこうなる
今回はまだシンプルだから設計できたけど、これもう2〜3件増えたら図に描くことができなくなるよな

これを踏まえて今回どうするか
あわてて変なレイヤーを入れたりしない
あわてて分割しない

「選択範囲の移動」と「グループ内の付箋を外に出す」を実装してテストケースを書いてからリファクタリングする

---

2021-07-21
一晩寝て気づいたこと
選択時に選択対象の要素を移し替えるなら
選択範囲ができた後、選択範囲の外をクリックしたとき、元に戻さなければならない
つまりここにも状態が発生している

状態はテストの対象になるべき

今はローカル変数が状態を持っているが、これをグローバルにする
before
ts
let isDragging = false; export const onMouseDown = (...) => { isDragging = true; ... }; export const onMouseMove = (...) => { if (isDragging) { ... } }; export const onMouseUp = (...) => { if (isDragging) { ... isDragging = false; } };

after
ts
export const onMouseDown = (...) => { updateGlobal((g) => { ... g.mouseState = "selecting"; }); }; export const onMouseMove = (...) => { const g = getGlobal(); if (g.mouseState === "selecting") { ... } }; export const onMouseUp = (...) => { const g = getGlobal(); if (g.mouseState === "selecting") { updateGlobal((g) => { ... g.mouseState = "selecting"; // intentional bug, should be "" }); } };

test
test.ts
cy.get("#canvas").trigger("mousedown", 50, 100); cy.getGlobal((g) => g.mouseState).should("to.eql", "selecting"); cy.get("#canvas").trigger("mouseup", 300, 400); cy.getGlobal((g) => g.mouseState).should("to.eql", ""); // intentional fail

これでおかしな状態になってる時に検知できるようになった
故意のバグはテストがちゃんとこけることを確認してから修正した

昨日の「dragstartの前にmousedownが来ることに気付いてなかった」をテストケースで検証
test.ts
cy.testid("1").trigger("dragstart", "center"); cy.getGlobal((g) => g.mouseState).should("to.eql", ""); cy.get("#canvas").trigger("drop", 250, 250);
これでfailするかと思ったら、しない、なるほど
人間が操作する時には先にmousedownが発生する
テストケースではdragstartイベントを発行してるだけだからmousedownが発生しない

これを現実のイベントと同じようにテストするとこうなるか
test.ts
it("is not selecting", () => { cy.testid("1").trigger("mousedown", "center"); cy.testid("1").trigger("mousemove", "center"); cy.testid("1").trigger("dragstart", "center"); cy.getGlobal((g) => g.mouseState).should("to.eql", ""); cy.get("#canvas").trigger("drop", 250, 250); });
これは期待通りにfailする
で、stopPropagationして期待通りに動くことを確認done

ここまではいい。次は朝気づいた「選択後」の状態について
選択範囲のドラッグで解除されるべきでない
選択範囲外のmousedownでは解除されるべき
あえて図に描くとこう
うーむ🤔
Aに解除コードを書くことはできる
しかし今後似たようなものが増えた時に書き漏らしそう
全部に自動的に解除コードをつけることはできない
Bで解除してはいけないから
自然言語でいうなら「選択範囲以外でのmousedownで解除」
この「以外」とは何か?
選択範囲にDOM的に包含されてる付箋でのmousedownは?
これは「DOMの重ね順序」(おっと、また新しいスコープだ)
DOMの重ね順序的に選択範囲は選択された付箋(の大部分)より手前にあるのでイベントをブロックするはず
Cのクリックで付箋にmousedownが発生しない
Dは選択範囲外クリックだから選択解除して良い
待てよ、ということは「選択されたものをdivに追加」ってやる時に別のdivが必要?
z-indexでいい?
いや、違うな、選択範囲を可視化するためのdivを選択範囲をまとめるために使う必要がない

「選択されてないもの」のopacityを下げることで選択されたものをハイライトすることにした
これはまだ状態の解除を実装してない

あー、ダメだ、選択範囲の表示をドラッグと選択されたオブジェクトを包むdivが分かれたら「選択範囲の表示」をドラッグしても選択されたオブジェクトが動かないじゃん…

複数個選択してドラッグで移動するところまではできたが「選択範囲の表示」が移動してない

できた

テストケースを書いた結果、期待した位置から縦方向に2ピクセルだけズレてることが明らかになった(苦笑
top:150pxのdivの(0, 2)をdragstartして、実際に発生するイベントは(150, 150)
Cypress環境でしか再現してなさそう...
test.ts
cy.getGlobal((g) => g.selected_items).should("to.eql", items); cy.getGlobal((g) => items.map((id) => g.itemStore[id].position)).should( "to.eql", [ [0, 0], [0, 200], [200, 0], [200, 200], ] ); cy.get("#selection-view").trigger("dragstart", 0, 2); // misterious 2px cy.get("#canvas").trigger("drop", 100, 100); cy.getGlobal((g) => items.map((id) => g.itemStore[id].position)).should( "to.eql", [ [-50, -50], [-50, 150], [150, -50], [150, 150], ] );


"Engineer's way of creating knowledge" the English version of my book is now available on [Engineer's way of creating knowledge]

(C)NISHIO Hirokazu / Converted from [Scrapbox] at [Edit]