はじめに
canvasに絵を描いて、それをドラッグで移動したい場面があります。
以前、svgの場合はreact-zoom-pan-pinch
を使うとさらっとできますよというお話でした。
一方、canvasの場合はそういうのがなさそうなので、自作してみました。
長くなりますが、このQiitaの記事としては、いきなり答えではなく考えた順番に実装していく感じで解説します。その方が実践っぽいでしょ。ちゃんと理解したいとか技術力をUPしたいとかの方は、自分で手を動かしながらじっくり読んでみてください。
動くサンプルはこちら
一気に最終系を知りたい人は、githubをご覧ください。
ソースはこちら
実装手順
1. 単純にcontextで描く
まずは通常のJavaScriptで描くように、contextで線を描きます。
原点から、X方向に50px、Y方向に100pxの線を引いただけです。向きが分かるように長さを変えました。
import { useRef, useEffect } from "react";
export function DraggableCanvas() {
// canvasへのref
const refCanvas = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = refCanvas.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
// 描画
context.lineWidth = 10;
context.beginPath();
context.moveTo(0, 0);
context.lineTo(50, 0);
context.moveTo(0, 0);
context.lineTo(0, 100);
context.stroke();
}, []);
return (
<>
<canvas
ref={refCanvas}
width={300}
height={200}
style={{border: "1px solid #000", margin: "10px"}}
/>
</>
)
}
結果
左上が原点で、右がx、下がy。
とりあえず絵は、最後までこのままでいきます。ドラッグの話なので、移動と拡大縮小がわかればいいだけなので。
2. 原点と拡大率を変数化
細かいアップデートですが、原点と拡大率を変数化します。
ここで、このプログラムの中で使う言葉を定義します。
座標系A:canvasの座標。左上が原点(0, 0)で、右下が(300, 200)の固定。
座標系B:ドラッグして移動する絵の原点。拡大縮小もする。(回転はしない)
座標系Bの原点がある座標系Aの座標値をoriginB
、拡大率をscale
。
originB
の初期値を、(50, 50)にしてみました。
あとPoint型を定義しました。
// x,y値を保持するためのtype
type Point = {
x: number;
y: number;
};
export function DraggableCanvas() {
// 以下は略
// 座標系Bの原点
let originB: Point = {x: 50, y: 50};
// 拡大率
let scale: number = 1.0;
// 描画
context.lineWidth = 10;
context.beginPath();
context.moveTo(originB.x, originB.y);
context.lineTo(originB.x + 50*scale, originB.y);
context.moveTo(originB.x, originB.y);
context.lineTo(originB.x, originB.y + 100*scale);
context.stroke();
結果
座標系Bの原点が(0,0)にあったときは、線の半分が画面外にあったけど、(50,50)へ移動して全体見えました。
家具を買うとついてくる工具にしか見えません。
3. マウスイベントリスナーの実装(空で)
マウスボタンを押した瞬間のmousedown
、マウスを動かしているときのmousemove
、マウスボタンを離したときのmouseup
の、イベントハンドラーを実装します。ハンドル3兄弟です。
useEffect(() => {
const canvas = refCanvas.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
// 座標系Bの原点
let originB: Point = {x: 50, y: 50};
// 拡大率
let scale: number = 1.0;
// 描画
function draw() {
if (!context) return;
context.lineWidth = 10;
context.beginPath();
context.moveTo(originB.x, originB.y);
context.lineTo(originB.x + 50*scale, originB.y);
context.moveTo(originB.x, originB.y);
context.lineTo(originB.x, originB.y + 100*scale);
context.stroke();
}
draw();
function handleMouseDown(event: MouseEvent){
console.log("mouseDown");
}
function handleMouseMove(event: MouseEvent){
console.log("mouseMove");
}
function handleMouseUp(event: MouseEvent){
console.log("mouseUp");
}
canvas.addEventListener("mousedown", handleMouseDown);
canvas.addEventListener("mousemove", handleMouseMove);
canvas.addEventListener("mouseup", handleMouseUp);
canvas.addEventListener("mouseleave", handleMouseUp);
return () => {
canvas.removeEventListener('mousedown', handleMouseDown);
canvas.removeEventListener('mousemove', handleMouseMove);
canvas.removeEventListener('mouseup', handleMouseUp);
canvas.removeEventListener('mouseleave', handleMouseUp);
};
}, []);
長くなるのでいったん空で一区切り。
一時的に、console.log()
を入れて、動いているのを確認してますがすぐ消します。
説明なしにmouseleave
のリスナーも入れてました。でも呼ばれる関数は、handleMouseUp
という不思議な感じですが、これはミスではないです。
ドラッグして絵を動かすときに、ドラッグしたままcanvasの外にマウスが出ることがありますよね。そのとき、mouseupの動作をして、ちゃんと終了するための仕組みです。
あ、あと描画関係を、draw()
に入れてその直後に呼んでます。ほぼ字下げしただけですが。
4. マウスイベントリスナーの中身を実装①
// ドラッグしている状態
let isDragging: boolean = false;
// ドラッグの開始位置
let dragStartPoint: Point | null = null;
function handleMouseDown(event: MouseEvent){
console.log("mouseDown");
// ドラッグ開始
isDragging = true;
dragStartPoint = {x: event.clientX, y: event.clientY};
}
function handleMouseMove(event: MouseEvent){
console.log("mouseMove");
}
function handleMouseUp(event: MouseEvent){
console.log("mouseUp");
originB = {x: event.
// ドラッグ終了
isDragging = false;
dragStartPoint = null;
}
今ドラッグしている最中であることを示すisDragging
と、ドラッグを開始した場所を示すdragStartPoint
を定義して、mousedown
で始めてmouseup
で終わるようにしました。
5. マウスイベントリスナーの中身を実装②
本筋からいったん離れ、マウスをぐっと押し下げてドラッグしている途中で呼ばれるgetOriginBDragging()
関数を定義します。
この関数は、今のマウス位置を与えると、まさに今ドラッグ途中のoriginB
の位置を返す関数です。
前提として、ドラッグ中にはoriginB
は更新せず、mouseup
したら更新するつもりなので、ここでのoriginB
はまだ更新されていない、ドラッグ開始時の位置です。
// ドラッグ中のoriginBを返す
function getOriginBDragging(curMousePos: Point): Point|void{
if (!isDragging) return;
if (!dragStartPoint) return;
return {
x: originB.x + curMousePos.x - dragStartPoint.x,
y: originB.y + curMousePos.y - dragStartPoint.y,
};
}
足し引きがややこしいですが、落ち着いて考えれば大丈夫です。
dragStartPos
からcurMousePos
へのベクトルを、originB
に足してます。
ベクトルA→Bは、Bの座標マイナスAの座標でしたね。懐かしすぎる。
6. マウスイベントリスナーの中身を実装③
もう1つ寄り道。
さきほど字下げしただけのdraw()
関数に引数を定義します。
元々、originB
を元に描画していましたが、引数のparamOriginB
を指定したらそれを使って、指定しなかったらこれまでと同じoriginB
を使います。
// 描画
function draw(paramOriginB?: Point) {
if (!canvas) return;
if (!context) return;
// この関数で使うoriginBを設定
// パラメータがあればそれを、なければoriginBを使う
const curOriginB = paramOriginB? paramOriginB: originB;
// 初期化
context.clearRect(0, 0, canvas.width, canvas.height);
context.lineWidth = 10;
context.beginPath();
context.moveTo(curOriginB.x, curOriginB.y);
context.lineTo(curOriginB.x + 50*scale, curOriginB.y);
context.moveTo(curOriginB.x, curOriginB.y);
context.lineTo(curOriginB.x, curOriginB.y + 100*scale);
context.stroke();
}
draw();
これは、mousemove
しているときに、originB
を更新しないけど、ドラッグ途中も絵を描き続ける必要があるためです。mousemove
しているときは、一時的なoriginB
相当の位置をgetOriginBDragging()
で計算して、それをここに渡すことができるようになりました。
あとついでに、context.clearRect
でcanvas全体を初期化もしてます。この関数はドラッグしてちょっと移動するたびに繰り返し呼ばれるので、これをやらないと、前の絵の上に重ね書きしてしまいます。
7. マウスイベントリスナーの中身を実装④
ようやくmousemove
の処理です。
function handleMouseMove(event: MouseEvent){
console.log("mouseMove");
if (!isDragging) return;
// 今のoriginBを取得
const movingOriginB = getOriginBDragging(
{x: event.clientX, y: event.clientY}
);
if (!movingOriginB) return;
// 描画
draw(movingOriginB)
}
getOriginBDragging()
で、今のマウスの位置を元に移動中のoriginB
を仮計算して、それをdraw()
に渡しています。道草の2つの呼び出しです。
ここまで実装すると、ようやくドラッグで移動するようになります。
試してみると~・・・
動くけども、ドラッグするmousedown
のタイミングで、初期位置に戻る。
gif画像の動画、クリックのタイミングがわからないのと、動画のスタート位置がわからないから、今回ものすごく相性が悪いですねw ぴっと飛ぶ感じになって、まさにこの動画で見ている通りなんですが、まぁいいや。
8. マウスイベントリスナーの中身を実装⑤
原因は、mouseup
時にoriginB
を更新していないからなので、更新します。
function handleMouseUp(event: MouseEvent){
console.log("mouseUp");
// 今のoriginBを取得
const finalOriginB = getOriginBDragging(
{x: event.clientX, y: event.clientY}
);
if (!finalOriginB) return;
// originBを更新
originB = finalOriginB;
// ドラッグ終了
isDragging = false;
dragStartPoint = null;
}
mouseup
時点のfinalOriginB
を取得して、それを本物のoriginB
へ上書きする処理を追加しました。mousemove
と似たような感じです。
8. スライドバーで拡大縮小①
移動が①~④とありましたが、拡大縮小も刻んでいきます。
最初は、拡大縮小について、仕様と動きを考えることから始めます。
拡大縮小の仕様は、2つの方法でできるようにします。
1つは、canvas内でマウスのホイール(ころころ)をトリガーに、上に回転させたら拡大、下に回転させたら縮小です。もう1つは、スライドで、左にスライドさせたら縮小、右にスライドさせたら拡大。
2つあるけど共通処理としたいので、その辺を考慮しながら作っていきます。
動きは、「指定した位置を中心に拡大縮小」です。
マウスホイールの場合は、マウスの位置を中心に、スライドの場合はcanvasの中心を中心に、拡大縮小します。意外と、拡大縮小には中心が存在しているということは、忘れがち。
拡大とは、絵を拡大することに加え、originB
を動かことになります。なぜならそれは拡大中心から、originB
が遠ざかるから。その2つの変形を同時にします。縮小は逆に絵の縮小と、originB
を近づける動きですね。
幸いscale
とoriginB
の2つとも独立した変数になっているので、それぞれの動きに合わせて変数を更新します。
scale
の値は、単純に値を増やせばいいだけから大丈夫でしょう。問題は、originB
をどれだけ動かすかということです。
拡大中心→originBのベクトルを利用して、拡大中心→newOriginBを導きます。元々拡大率がS1
だったところから、S2
に変わったときを考えると、ベクトルはS2/S1
倍になります。
コードだとこう。
// 拡大によるoriginBとscale値の更新
function zoomAround(newScale: number, propCenter?: Point){
if (!refCanvas) return;
if (!refCanvas.current) return;
// 拡大中心点(座標系A)
const zoomCenter: Point = propCenter
? propCenter
: {
x: refCanvas.current.width/2,
y: refCanvas.current.height/2
}
;
// 拡大中心点→originBinAを newScale/scale 倍した先が
// 新しいoriginBとなる
// zoomCenter → OriginB ベクトル
const vecZC2OB: Point = {
x: (originB.x - zoomCenter.x) * newScale / scale,
y: (originB.y - zoomCenter.y) * newScale / scale,
};
// 新しいoriginBの座標値
const newOriginB = {
x: zoomCenter.x + vecZC2OB.x,
y: zoomCenter.y + vecZC2OB.y,
};
// 計算結果で更新
originB = newOriginB;
scale = newScale;
}
vecZC2OB
というのが絵で描いた紫のベクトルです。拡大中心zoomCenter
にそのベクトルを足すと、newOriginB
が計算できます。縮小の場合でもまったく同じ計算なので問題なし。
引数のpropCenter
が指定されなかった場合は、canvasの中心の座標値をcenterとする処理も入れています。これは、スライドで拡大されることを想定しています。
なお関数名のzoomAround
は英語的に良いのか微妙ですが、ネーミングの意図は、「指定した位置を中心に拡大縮小する」という感じです。
9. スライドバーで拡大縮小②
さて、準備ができたところで、スライドバー自体の実装。
<div style={{display:"flex",flexDirection:"column", alignItems: "flex-start", padding: "10px"}}>
<canvas
ref={refCanvas}
width={300}
height={200}
style={{border: "1px solid #000"}}
/>
<input
type="range"
ref={refRange}
min={SLIDERRANGE_MM.min}
max={SLIDERRANGE_MM.max}
defaultValue={0}
/>
</div>
もともと、canvasだけだったところの下に、inputタグのtype="range"を追加します。HTML5で追加されたタイプです。これは便利。
1目盛り動かすと、valueの数値が1上下します。属性にstepを設定して、valueの増減の幅も設定できるタグですが、ここでは1とします。そして、min
・max
で、スライドの左端・右端のときの値、defaultValue
はReact特有の属性で初期値です。
そのinputを置いたら、横に並んでしまったので、上位のタグをdivにして、display:"flex", flexDirection:"column"
などのスタイルをつけて縦に並べています。見た目の問題なのであまり気にしなくて大丈夫です。
上の方で、SLIDERRANGE_MM
を定義します。定義するだけ。
10
のときに10倍、-10
のときに0.1倍になったらいいなぁという気持ちで。
const SLIDERRANGE_MM = {
min: -10,
max: 10,
};
DraggableCanvas()の先頭で、ref
属性で指定していたrefを、元々あったrefCanvasの下に、追加します。
ついでに、useEffect()
の中でrefRange.current
からrangeを取得する処理も書いておきます。後でrangeをさっと使えるように。(range
は言語によっては予約語として存在してそうだから、別の名前にした方がよかったか🤔)
export function DraggableCanvas() {
// canvasへのref
const refCanvas = useRef<HTMLCanvasElement>(null);
// input rangeへのref
const refRange = useRef<HTMLInputElement>(null);
useEffect(() => {
const canvas = refCanvas.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
const range = refRange.current;
if (!range) return;
いったんここまで。スライドは動かせるけど、スライドを動かしても絵は何も動かない状態です。それでOK。
10. スライドバーで拡大縮小③
スライドバーのinput値は、-10
~10
でした。
それに対する拡大率は、大体こんな風に対応付けたいです。
-10の時0.1倍 ・・・ 0の時1.0倍 ・・・ 10の時10.0倍
どうせ手でスライドさせるようなことだし、わかりやしないので適当に、数式的には下記にしてみました。
scale = 10^{\frac{value}{10}}
ただの数式なので、外に関数を定義します。
// スライドバーのスケール値から、座標系Bのscale値へ変換する
function rangeValue2Scale(value: number) {
return Math.pow(10, value/10);
}
11. スライドバーで拡大縮小④
ようやく、スライドを動かしたら動くハンドラー関数を追加します。
まずイベントリスナーを追加。
canvas.addEventListener("mousedown", handleMouseDown);
canvas.addEventListener("mousemove", handleMouseMove);
canvas.addEventListener("mouseup", handleMouseUp);
canvas.addEventListener("mouseleave", handleMouseUp);
range.addEventListener("input", handleRangeChange); // input range
return () => {
canvas.removeEventListener('mousedown', handleMouseDown);
canvas.removeEventListener('mousemove', handleMouseMove);
canvas.removeEventListener('mouseup', handleMouseUp);
canvas.removeEventListener('mouseleave', handleMouseUp);
range.removeEventListener('input', handleRangeChange); // input range
};
handleRangeChange
を追加しています。
ここで、listenするイベントは、input
を指定しています。最初、change
で実装したところ、スライドを動かしてみると、動かした後のmouseupのタイミングだけでしか発動しないのです。拡大縮小は、ぐりぐり動かしながら適切なところを探るので、これはイカンと思って調べた結果です。
関数名は、"change"の方が分かりやすいからそうしてます。
リスナーから呼び出される関数handleRangeChange
です。
function handleRangeChange(){
if (!range) return;
// 値から拡大率を計算
const newScale = rangeValue2Scale(range.valueAsNumber);
// 拡大縮小
zoomAround(newScale);
// 再描画
draw();
}
12. マウスホイールで拡大縮小
スライドバーの次はマウスホイールからの拡大縮小。とはいえ、共通処理が多いので、新たな処理は少ないですよ。長丁場ですがもう少し。
まずリスナーを登録します。
canvas.addEventListener("mousedown", handleMouseDown);
canvas.addEventListener("mousemove", handleMouseMove);
canvas.addEventListener("mouseup", handleMouseUp);
canvas.addEventListener("mouseleave", handleMouseUp);
canvas.addEventListener("wheel", handleMouseWheel); // wheel
range.addEventListener("input", handleRangeChange);
return () => {
canvas.removeEventListener('mousedown', handleMouseDown);
canvas.removeEventListener('mousemove', handleMouseMove);
canvas.removeEventListener('mouseup', handleMouseUp);
canvas.removeEventListener('mouseleave', handleMouseUp);
canvas.removeEventListener("wheel", handleMouseWheel); // wheel
range.removeEventListener('input', handleRangeChange);
};
"wheel"というイベントをlistenするようにします。マウスホイールによるイベントのことです。
それによって呼び出される関数がこちら。引数のイベントは、WheelEvent
型です。
function handleMouseWheel(event: WheelEvent){
if (!range) return;
// マウスホイールの方向による更新値
const wheelUp = (event.deltaY<0)? 1: -1;
// 今のinputRangeから1つ上げ下げする
const newInputRangeValue = range.valueAsNumber + wheelUp;
range.value = String(newInputRangeValue);
// inputRangeValueからscale値に変換
const newScale: number = rangeValue2Scale(
newInputRangeValue
);
// マウス位置を中心に拡大縮小
zoomAround(
newScale,
{x: event.clientX, y: event.clientY}
);
// 再描画
draw();
}
まずWheelEvent
から、特有のプロパティdeltaY
を見て、マイナスの場合は上へ回しているので、その場合はwheelUp = 1
として、逆は-1
とします。実際は、deltaY
は100
とか入ってるのですが、回した方向だけ利用したいため無視します。
次に、現在のスライドバーの数値にwheelUp
の値を足して、更新します。つまり、上へ回すと、スライドバーの値が+1
されるので、スライドバーを1目盛り右へずらしたのと同じ効果。
それと同時にそのスライドバーの値を元にしたスケール値をrangeValue2Scale()
で得て、それを使ってzoomAround()
で拡大縮小します。
拡大縮小の時には、マウスの位置を拡大縮小の中心とするため、{x: event.clientX, y: event.clientY}
を渡します。
そして再描画。
いやーうれしくてつい長いgifアニメになってしまいました。
canvasをドラッグで移動、拡大縮小をする説明はおしまいです!長い記事にお付き合いくださいましてありがとうございました。
もう一度、動くサンプルはこちら
おわりに
調べるまでもなく過去イチで長い記事になりました。コーディング中は、そうでもなかったんですが、考えていることを逐一書き出すとこうなっちゃいますね。
コーディングの考え方とか進め方を共有するのはYouTubeだといいのかなぁとも思いますが、検索で途中のコードとかは出てこないし、見つけづらいので、がんばって書いてみたのですが、どうだったんでしょう。ちょっと時間を置いて様子をみてみようと思います。
必要とされてそうだったらまたやるかも。体力と気力があるときに。