マウスイベント周りの問題、前回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],
]
);