はじめに
こんにちは。
最近、SNSのOGPやブログのアイキャッチ画像を作るときに、「比率を固定してサクッと切り抜きたいだけなのに、高機能なソフトを立ち上げるのは面倒…」と感じることがありました。
そこで、「サーバーへのアップロード不要(安心)」「ライブラリ依存なし(軽量)」 な画像トリミングツール 「Pita Tori(ぴたとり)」 を自作してみました。(登録必要なし、無料、出力画像への権利主張は行いません)
今回は、ReactやVueなどのフレームワークを使わず、素のHTML+CSS+JavaScript(いわゆるバニラJS)とCanvas APIだけで、どのように座標計算やトリミング処理を実装したのか、その裏側を解説します。
作ったもの:Pita Tori (ぴたとり)
Pita Tori - 劣化なし画像トリミング
製作者ポートフォリオ
主な機能
- 完全クライアントサイド処理: 画像データはサーバーに送信されません。
- 比率固定クロップ: 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 },
// ...その他モード管理フラグなど
};
- 描画ループ(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);
}
- マウス操作の座標計算
ユーザーがマウスで枠をドラッグした際、どれくらい枠を移動させるか?の計算でも 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();
}
- 実際に切り抜く(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つで動くため、動作も非常に軽量です。
「ちょっと画像を切り抜きたい」という時は、ぜひ使ってみてください。
