画像 編集 javascript のトリミングUI 9選
画像 編集 javascript の入口は、写真から残す範囲を選んで切り抜く UI です。ここに並べた九つはアップロードなしでブラウザーの中だけで完結するように、canvas の 2D コンテキストと Pointer Events だけで組みました。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 自由トリミング枠
- 02 回転と水平補正
- 03 フィルタープリセット六つ
- 04 明るさ・コントラスト・彩度のスライダー
- 05 左右と上下の反転
- 06 正方形の余白埋め
- 07 テキスト透かし
- 08 RGB ヒストグラム
- 09 書き出しパネル
九つの並びは機能の派手さではなく、写真一枚が編集画面を通っていく道順です。まず形を決める三つ — 残す範囲をハンドルでつかみ(01)、傾いた水平を角度で立て直し(02)、トーンをプリセットで一度に着せ替えます(03)。次は数値を整える三つ — 明るさ・コントラスト・彩度を別々に動かし(04)、軸を反転させ(05)、規格に合わせて余白を埋めます(06)。最後の三つは書き出す直前の仕事で、文字を載せ(07)、ピクセルの分布でやりすぎていないか確かめ(08)、形式と画質を選んでファイルを作ります(09)。写真は外部ファイルを一枚も使わず、canvas 上にグラデーションと図形で描き起こしました。手で描く側の話はcanvas 手書きサインと描画UI 9選に、色を選ぶ側はカラーピッカー UI 9選にあります。
01自由トリミング枠
角四つと辺四つ、合わせて八つのハンドルで残す範囲をつかむ、いちばん基本のパネルです。外側を暗くする覆いは、外周を一周してから内側の穴を逆向きに回る十点の clip-path 多角形ひとつで描き、結果の枠は、引数を九つ取る drawImage が切り出した長方形だけを描き直します。右下のハンドルを Playwright でドラッグすると 162 × 112 だった範囲は 111 × 72 まで縮み、プレビューでは累積面積 18.917%・動いたフレーム 19/23 を記録しました。
// 外側を暗く覆う影。外周を一周してから内側の穴を逆向きに回る十点の多角形です
shade.style.clipPath = 'polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%, ' +
x + '% ' + y + '%, ' + x + '% ' + (y + h) + '%, ' + (x + w) + '% ' + (y + h) + '%, ' +
(x + w) + '% ' + y + '%, ' + x + '% ' + y + '%)';
var sx = photo.width * x / 100, sy = photo.height * y / 100;
var sw = photo.width * w / 100, sh = photo.height * h / 100;
var pw = Math.round(sw), ph = Math.round(sh);
// 結果の枠は切り出した大きさそのままで作ります。固定の正方形に描くと比率がつぶれます
out.width = pw; out.height = ph;
// 九引数の drawImage — 元画像から切り出す長方形と、描き込む長方形を別々に渡します
octx.drawImage(photo, sx, sy, sw, sh, 0, 0, pw, ph);
02回転と水平補正
角度を動かすと写真が傾き、四隅に余白ができないように必要な拡大率も同時に計算します。12 度のときの拡大率 1.31 倍は勘ではなく、傾いた長方形の横辺と縦辺がどれだけ伸びるかを計算すれば、そのまま出てくる値です。プレビューは傾いた状態から始まり、水平に戻してからまた傾き、累積面積 22.342%・強度 65.5 を記録しました。
// 傾いた長方形が元の枠を覆いきるには、どれだけ拡大すればよいか。
// 横辺は w·|cos| + h·|sin|、縦辺は w·|sin| + h·|cos| に伸びるので、大きいほうが拡大率です
function zoomFor(rad, w, h) {
var c = Math.abs(Math.cos(rad)), s = Math.abs(Math.sin(rad));
return Math.max((w * c + h * s) / w, (w * s + h * c) / h);
}
vctx.setTransform(1, 0, 0, 1, 0, 0);
vctx.clearRect(0, 0, w, h);
vctx.translate(w / 2, h / 2);
vctx.rotate(rad);
vctx.scale(k, k);
vctx.drawImage(src, -w / 2, -h / 2, w, h);
03フィルタープリセット六つ
元画像・モノクロ・セピア・くっきり・落ち着き・ビンテージの六つが、それぞれ filter 文字列ひとつだけで定義されていて、押すと大きな写真と下の文字が同時に変わります。六つの状態を続けて回すのはプレビューのときだけで、そのときは関数の並びを六つにそろえて書いています。計算結果はプリセットと同じですが、並びが違うとブラウザーは補間をあきらめ、トーンがぶつ切りになります(累積面積 19.97%・動いたフレーム 10/23)。
// 関数の並びが違うとブラウザーは補間をあきらめます。足りない関数は恒等値で埋めて長さをそろえます
@keyframes fpChain {
0%, 10% { filter: grayscale(0) sepia(0) saturate(1) contrast(1) brightness(1) hue-rotate(0deg); }
14%, 24% { filter: grayscale(1) sepia(0) saturate(1) contrast(1.08) brightness(1) hue-rotate(0deg); }
28%, 38% { filter: grayscale(0) sepia(0.75) saturate(1.2) contrast(1) brightness(1) hue-rotate(0deg); }
42%, 52% { filter: grayscale(0) sepia(0) saturate(1.35) contrast(1.3) brightness(1) hue-rotate(0deg); }
56%, 66% { filter: grayscale(0) sepia(0) saturate(0.55) contrast(1) brightness(1.08) hue-rotate(0deg); }
70%, 85% { filter: grayscale(0) sepia(0.4) saturate(1) contrast(0.9) brightness(1) hue-rotate(-14deg); }
89%, 100% { filter: grayscale(0) sepia(0) saturate(1) contrast(1) brightness(1) hue-rotate(0deg); }
}
04明るさ・コントラスト・彩度のスライダー
三つの値はばらばらに働かず、filter 文字列ひとつにまとまるので、書いた順番がそのまま適用の順番になります。比較ボタンはその文字列を none に差し替えて同じ場所に元画像を出し、隅のラベルの文字も一緒に変わります。明るさのトラックを右へドラッグすると brightness(1.35) と読み上げ用の「1.35 倍」が同時に更新されることを実測しました(累積面積 15.922%・強度 56.2)。
// スライダー三つが filter 文字列ひとつにまとまります。書いた順番がそのまま適用の順番です
var chain = 'brightness(' + value.b.toFixed(2) + ') contrast(' + value.c.toFixed(2) + ') saturate(' + value.s.toFixed(2) + ')';
view.style.filter = showOriginal ? 'none' : chain;
code.textContent = chain;
tag.textContent = showOriginal ? TAG_ORIG : TAG_EDIT;
for (var i = 0; i < tracks.length; i++) {
var key = tracks[i].getAttribute('data-k'), v = value[key];
thumbs[key].style.left = ((v - 0.5) * 100) + '%';
tracks[i].setAttribute('aria-valuenow', v.toFixed(2));
tracks[i].setAttribute('aria-valuetext', VOICE.replace('%v', v.toFixed(2)));
}
05左右と上下の反転
やることは軸をひとつマイナスに反転させるだけですが、そのままでは絵が canvas の外へ出てしまうので、その辺の長さだけ先にずらしておく必要があります。プレビューが四つの状態を steps(1, end) で切り替えるのは、1 から -1 へ連続で補間すると途中で幅が 0 になる場面ができ、代表画像が線一本になってしまうからです。切り替えだけでは動いたフレームが足りないので、光の帯をひとつ流して 20/23 を満たしました(累積面積 29.441%)。
var w = view.width, h = view.height;
vctx.setTransform(1, 0, 0, 1, 0, 0);
vctx.clearRect(0, 0, w, h);
// 軸をマイナスに反転すると絵が画面の外へ出るので、その分だけ先にずらしておきます
vctx.translate(flipX ? w : 0, flipY ? h : 0);
vctx.scale(flipX ? -1 : 1, flipY ? -1 : 1);
vctx.drawImage(src, 0, 0, w, h);
06正方形の余白埋め
横長の写真を正方形の規格に入れるとき、切り落とす代わりに余った場所を塗るやり方です。拡大率は内側の大きさを写真の横と縦でそれぞれ割った値のうち小さいほうなので、長い辺が先に当たって切れません。背景色四つを一フレームで差し替え、余白だけを動かし続けたので、動きの途切れにくいループになりました(22/23 フレーム・強度 99.5)。
var s = out.width;
// まず正方形の全面を選んだ色で塗り、その上に写真を中央へ置きます
octx.fillStyle = bg;
octx.fillRect(0, 0, s, s);
var inner = s * (1 - pad * 2);
var k = Math.min(inner / src.width, inner / src.height);
var dw = src.width * k, dh = src.height * k;
octx.drawImage(src, (s - dw) / 2, (s - dh) / 2, dw, dh);
07テキスト透かし
文字をドラッグして置き場所を決め、大きさと影を整えて canvas に焼き込みます。明るい空の上では白い文字が沈むので、shadowBlur と縦方向のオフセットを一緒に掛けないと読めず、トグルを切るとどちらも 0 に戻ります。プレビュー中に canvas が写真だけを描き、文字は DOM の幽霊板が代わりに動くのは、一度焼き込んだ canvas の文字が CSS では動かせないからです(累積面積 6.148%・強度 77.7)。
vctx.textAlign = 'center';
vctx.textBaseline = 'middle';
vctx.fillStyle = 'rgba(255, 247, 230, 0.94)';
vctx.shadowColor = 'rgba(23, 20, 26, 0.62)';
// 影は文字の下ににじむ程度だけ。0 にすると明るい空の上で文字が沈みます
vctx.shadowBlur = shadowOn ? 9 : 0;
vctx.shadowOffsetY = shadowOn ? 2 : 0;
vctx.fillText(mark, spot.x * w, spot.y * h);
08RGB ヒストグラム
補正を掛けた写真を小さな板に描き直し、getImageData でピクセルをまるごと読んで、チャンネルごとに十六の区間へ数え分けます。バイトを四ビット右へずらすとそのまま区間の番号になり、256 段階を全部数えると棒が細すぎて形が読めません。明るさを 1.55 倍まで上げたとき、棒が塗った面積が 16.44% から 8.65% まで減ることを実測しました(累積面積 16.857%・動いたフレーム 23/23)。
// ctx.filter は Safari にまだありません(caniuse 81.08%)。無ければ読んだ値に直接掛けて同じ結果を作ります
var CAN_FILTER = typeof wctx.filter === 'string';
function countBins() {
if (CAN_FILTER) { wctx.filter = 'brightness(' + level.toFixed(2) + ')'; }
wctx.clearRect(0, 0, work.width, work.height);
wctx.drawImage(view, 0, 0, work.width, work.height);
var data = wctx.getImageData(0, 0, work.width, work.height).data;
var bins = [], i;
for (i = 0; i < BINS * 3; i++) { bins.push(0); }
for (var p = 0; p < data.length; p += 4) {
for (var ch = 0; ch < 3; ch++) {
var v = CAN_FILTER ? data[p + ch] : Math.min(255, Math.round(data[p + ch] * level));
bins[Math.min(BINS - 1, v >> 4) * 3 + ch] += 1;
}
}
return bins;
}
09書き出しパネル
容量は計算式で見積もらず、toBlob で実際にファイルを作ってそのサイズを読みます。同じ写真 320 × 200 を基準にすると PNG が 12.4 KB、JPG の画質 0.8 が 5.8 KB、WEBP の画質 0.8 が 3.3 KB、画質を 0.5 に下げると 2.4 KB になりました。PNG は可逆で画質の引数をまったく無視するので、そのときはスライダーを薄くして aria-disabled を立てます(累積面積 2.169%・強度 108.0)。
// 選んだ形式をブラウザーが知らないと、仕様どおり黙って PNG が返ります。image/webp は
// Safari にまだありません(caniuse 81.11%)。blob.type と見比べて、実際に出た形式も併記します
function label(blob) {
if (!blob) { return '— KB'; }
return (blob.size / 1024).toFixed(1) + ' KB' + (blob.type === mime ? '' : ' · ' + blob.type);
}
function estimate() {
view.toBlob(function (blob) { sizeEl.textContent = label(blob); }, mime, quality);
}
どこで壊れるか — 落とし穴
この編でいちばん静かに壊れるのは、canvas 側の二つの機能が Safari にだけないことです。CanvasRenderingContext2D.filter は全体で 81.08%、toBlob の image/webp は 81.11% で、欠けているのはどちらも Safari です。しかも二つとも例外を投げません。08 は補正の掛かっていない元画像を数えて棒が動かなくなり、09 は WEBP を選んだのに仕様どおり PNG が返ってきて、容量だけが倍以上に膨らみます。そこで 08 は typeof wctx.filter === 'string' で対応を確かめ、無ければ読んだバイトに直接掛けるようにし、09 は blob.type を選んだ形式と見比べて実際に出た形式を併記するようにしました。いっぽう CSS の filter は 97.02% なので、03 と 04 はそのまま使えます。
| 機能 | 全体の対応 | 欠けている場所 | この編の対処 |
|---|---|---|---|
CSS の filter |
97.02% | なし | 03・04 がそのまま使用 |
ctx.filter |
81.08% | Safari(18 以降も既定で無効) | 08 が画素に直接掛ける |
toBlob の image/webp |
81.11% | Safari の全バージョン | 09 が blob.type を表示 |
二つ目の落とし穴は、プレビューと画面の文字がずれることでした。03 で選択枠だけをなめらかに滑らせたところ、枠だけが先に到着したフレームが代表画像に選ばれて「くっきりの枠に、下はセピアの値」という画になり、枠と文字を同じ瞬間に切り替えてようやく合いました。三つ目はコントラストです。06 のオレンジの舞台に置いたクリーム色の見出しは #ff4d1f に対して 3.11:1 で AA 未満だったため墨色に変えて 5.50:1 になり、05・07 の押された丸ボタンも #2f6df6 の上のクリームが 4.25:1 だったので白に上げて 4.53:1 にしました(根拠: run/295/_수리전실측.json・run/295/_probe_contrast/_대비실측.json)。九つの編集画面をまとめて受け取る zip の鍵は z3y6mq2c で、開くと項目番号ごとに vanilla 版と React 版が同じ名前で並んでいます。出典は MDN drawImage と caniuse です。
アクセシビリティ(reduced-motion)
canvas はスクリーンリーダーにはただの絵なので、九つすべてに role="img" と aria-label を付け、01・03・05 は値が変わるたびにそのラベルを書き直します(01 は「切り抜き範囲 111 × 72 ピクセル」と読みます)。スライダー八つは role="slider" に aria-labelledby(02 だけ aria-label)で名前をつなぎ、aria-valuenow と aria-valuetext を更新し、トグルは本物のボタンに aria-pressed を付けています。キーボードだけでも同じ操作ができます。01 のハンドルにフォーカスを当てて右矢印を十回押すと範囲が 162 × 112 から 152 × 112 へちょうど 10px 縮み、Shift と一緒に左矢印を一回押すと 162 × 112 に戻ります。prefers-reduced-motion では九つのプレビューを !important で止め、編集が終わった状態を残すので、02 は傾いた写真と -12.0 度の値が、06 は墨色の背景に余白を広げた正方形がそのまま残ります。
FAQ
写真をサーバーへ送らずに切り抜けますか。
できます。九つとも、動くのはブラウザーの中だけで、ネットワーク通信は一切ありません。利用者が選んだファイルは URL.createObjectURL で <img> に読み込ませ、その画像を drawImage の第一引数に渡せば同じように扱えます。このデモはその代わりに canvas で合成したダミー写真を置き、ファイルがなくても開くようにしています。
切り抜いた結果を元の解像度で保存するには。
画面上の大きさではなく、元画像のピクセルを基準に計算します。01 は切り抜き範囲をパーセントだけで持っていて、描くときに photo.width と掛けるので、元が 4000px でも同じパーセントから 4000px 基準の長方形を切り出せます。結果の canvas の width と height をそのピクセル数に合わせておくのがコツです。
編集の前後を並べて見比べる画面はどう作りますか。
04 は同じ場所で元画像と補正後を入れ替える A/B トグルです。二枚の境目を左右にドラッグして見比べる形が必要ならビフォーアフター スライダー UI 9選がその型を扱っていて、目的が違うので両方あっても衝突しません。