4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【ライブラリ不要】バニラJSとCanvasだけで、高機能な画像トリミングツールを作ってみた(比率固定・ズーム対応

4
Posted at

はじめに

こんにちは。

最近、SNSのOGPやブログのアイキャッチ画像を作るときに、「比率を固定してサクッと切り抜きたいだけなのに、高機能なソフトを立ち上げるのは面倒…」と感じることがありました。

そこで、「サーバーへのアップロード不要(安心)」「ライブラリ依存なし(軽量)」 な画像トリミングツール 「Pita Tori(ぴたとり)」 を自作してみました。(登録必要なし、無料、出力画像への権利主張は行いません)

今回は、ReactやVueなどのフレームワークを使わず、素のHTML+CSS+JavaScript(いわゆるバニラJS)とCanvas APIだけで、どのように座標計算やトリミング処理を実装したのか、その裏側を解説します。

作ったもの:Pita Tori (ぴたとり)

Pita Tori - 劣化なし画像トリミング
製作者ポートフォリオ

image.png

主な機能

  • 完全クライアントサイド処理: 画像データはサーバーに送信されません。
  • 比率固定クロップ: 16:9, 1:1, OGP用(1200:630)などプリセットを用意。
  • 操作性: ズーム、パン(移動)、枠の変形に対応。
  • 構成: HTMLファイル1枚(CSS/JS内包)で完結。

技術的な実装ポイント

今回の実装で一番の肝となったのは、「Canvas上の表示座標」と「実際の画像のピクセル座標」の相互変換です。

1. 座標管理の仕組み

Canvasに画像を表示する際、巨大な画像をそのまま表示すると画面からはみ出してしまうため、画面サイズに合わせて縮小表示(viewScale)しています。
しかし、トリミング実行時には「元の解像度」で切り抜く必要があります。

この管理のために、以下のようなステートオブジェクトを設計しました。

let state = {
    // 元画像(フル解像度)を保持するオフスクリーンCanvas
    srcCanvas: document.createElement('canvas'), 
    
    // 画面表示用のスケールとオフセット
    viewScale: 1.0,      
    viewOffset: { x: 0, y: 0 },
    
    // 切り取り枠の実座標(srcCanvas上のピクセル位置)
    cropRect: { x: 0, y: 0, w: 0, h: 0 },
    
    // ...その他モード管理フラグなど
};
  1. 描画ループ(Render関数)
    ライブラリを使わない場合、描画の優先順位管理は自分で行う必要があります。今回はシンプルに render() 関数に集約しました。ポイントは、「画像」を描画した後に、計算した座標に基づいて「枠」を上書き描画する点です。
function render() {
    if (!state.imageLoaded) return;
    ctx.clearRect(0, 0, viewCanvas.width, viewCanvas.height);

    const { x, y } = state.viewOffset;
    const s = state.viewScale; // 表示倍率

    // 1. 画像を描画(表示位置とスケールを適用)
    ctx.drawImage(state.srcCanvas, x, y, state.srcCanvas.width * s, state.srcCanvas.height * s);

    // 2. 切り取り枠の描画
    // ここで「元画像の座標(cropRect)」を「画面上の座標(rx, ry)」に変換しています
    const rx = x + state.cropRect.x * s;
    const ry = y + state.cropRect.y * s;
    const rw = state.cropRect.w * s;
    const rh = state.cropRect.h * s;

    // 視認性を上げるため、白線と破線の2重線で描画
    ctx.strokeStyle = '#fff';
    ctx.lineWidth = 2;
    ctx.strokeRect(rx, ry, rw, rh);
    
    ctx.strokeStyle = '#007bff'; // アクセントカラー
    ctx.setLineDash([5, 5]);     // 破線
    ctx.strokeRect(rx, ry, rw, rh);
}
  1. マウス操作の座標計算
    ユーザーがマウスで枠をドラッグした際、どれくらい枠を移動させるか?の計算でも viewScale が重要になります。
    マウスが 10px 動いたとしても、表示倍率が 0.5 なら、実際の画像上では 20px 動かす必要があるからです。
// マウス移動時の処理(抜粋)
if (state.isDraggingBox) {
    const s = state.viewScale;
    
    // マウスの移動量をスケールで割って、元画像上の座標に反映させる
    state.cropRect.x = (mouse.x - state.dragStart.x - state.viewOffset.x) / s;
    state.cropRect.y = (mouse.y - state.dragStart.y - state.viewOffset.y) / s;
    
    clampCropRect(); // 画面外にはみ出さないように補正する関数
    render();
}
  1. 実際に切り抜く(Crop実行)
    最後の切り抜き処理は drawImage の引数をフル活用します。
    Canvas APIの drawImage は、切り抜き元と描画先の座標を指定できるため、ここでもステートの情報をそのまま渡すだけで済みます。
function performCrop() {
    const { x, y, w, h } = state.cropRect;
    
    // 出力用キャンバスのサイズ設定
    resCanvas.width = w;
    resCanvas.height = h;

    // drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh)
    // sx, sy... : 元画像の切り抜き開始位置とサイズ
    // dx, dy... : 出力先キャンバスの描画位置とサイズ
    resCtx.drawImage(state.srcCanvas, x, y, w, h, 0, 0, w, h);
}

苦労した点:UXの作り込み

単に切り抜くだけなら簡単ですが、「使いやすいツール」にするために以下の点にこだわりました。

クリップボードからのペースト対応: window.addEventListener('paste', ...) を実装し、スクショをとって Ctrl+V だけで編集に入れるようにしました。

スマホ写真の「向き」問題: iOSなどで撮影した写真はExif情報によって向きが変わることがあるため、手動の「90度回転ボタン」を実装してCanvasごと回転させる処理を追加しました。

比率固定の維持: リサイズ時にアスペクト比が崩れないよう、Width変更時にHeightを自動計算するロジック(adjustCropToRatio)を組み込みました。

まとめ

外部ライブラリを使えば数行で書ける機能かもしれませんが、「座標計算」や「イベントハンドリング」を自前で書くことで、Canvas操作の基礎力がかなり鍛えられました。

また、HTMLファイル1つで動くため、動作も非常に軽量です。
「ちょっと画像を切り抜きたい」という時は、ぜひ使ってみてください。

4
1
0

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
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?