JavaScriptで写真の上を自由に動かせる注釈を作ってみる
以前、「CSSでクリッカブルマップのように画像へ注釈を入れる例」という記事で、画像の上に注釈を表示する例を紹介しました。
今回はその注釈テキストをJavaScriptを使って、自由に追加/編集/移動/削除ができるようにしたら楽しいのではないか、と思いサンプルを作ってみました。
もちろんjQueryは不要で、標準的なJavaScriptのみで大丈夫。
JavaScriptで画像上の注釈を操作する
まずは言葉で説明するより見たほうが早いと思うので動作サンプルです。
■操作説明
- 写真上に配置された注釈テキストをクリックすると文章の編集が可能
- 注釈左上の丸いハンドル「●」をクリック(またはタップ)すると注釈の移動モードになる
- 注釈右上の「×」ボタンをクリックすると注釈の削除
- 注釈を追加ボタンをクリックすると新しい注釈の追加
尚、すべてはJavaScript内で処理されており、Webサーバーと通信を行って座標位置を保存するような機能は実装していません。
(が、それを実装するのもXMLHttpRequestを使えばさほど難しくはないでしょう)
HTML部分の解説
<button onclick="addNote(10,10,120,22);">注釈の追加</button>
<div id="note_base">
<img src="[画像ファイル名]">
</div>
<div id="pos"></div>
HTML部分は実にシンプルです。
- 新しい注釈を追加するためのボタン
- レスポンシブデザインに対応するため、注釈の配置を相対座標指定しており、そのためのベース座標となるdivをid="note_base"として定義
- note_baseの中に画像を配置
- 一番下のid="pos"は注釈の座標位置を表示するだけのいわゆるデバッグ的な機能
CSS部分の解説
#note_base {
position:relative;
max-width:600px;
}
#note_base > img {
max-width:100%;
pointer-events:none;
}
.note {
position:absolute;
}
.note textarea {
margin-top:0.5em;
overflow:hidden;
background-color:transparent;
border:0;
border-bottom:1px solid white;
border-left:1px solid white;
color:white;
}
.note span.mark {
display:inline-block;
position:absolute;
top:-0.3em;
left:-0.3em;
cursor:pointer;
border-radius:50%;
width:0.8em;
height:0.8em;
background-color:white;
}
.note span.remove {
display:inline-block;
position:absolute;
right:0;
top:0;
cursor:pointer;
color:white;
}
.note span.remove:hover {
color:red;
}
#note_base
HTML部分でも軽く触れましたが、注釈要素は相対座標指定、つまりtopとleftプロパティにパーセンテージで位置指定しているため、その基点となるdiv要素が必要です。それがこの#note_baseで、position:relative;を指定しています。
max-width:600pxの部分は別に100%指定でも特に問題ありません。
#note_baseの中にはimgタグで写真を配置しますが、pointer-events:none;が重要。
というのも、#note_baseでマウスのクリックや移動といったイベント処理をしているため、画像上のイベントは拾いたくないんですね。そのためのpointer-events:none;指定。
HTML上の要素をいろいろ重ね合わせてマウスやタップで操作するときにはとても大事。JavaScriptからもpointer-eventsは操作できるので、移動させたいオブジェクトだけ有効にしたりね。今回はもっとシンプルなのでそういうことはしていませんけども。
.note
このnoteクラスが注釈用のクラス。HTML上では一切使われていないように見えますが、それもそのはず、JavaScriptのcreateElementで動的に要素を生成して、このnoteクラスを設定しています。
#note_baseの子要素として相対座標指定するため、position:absolute;は必須。noteの中身は文章用のtextarea、移動モード切り替え用の「●」を描画しているspan.mark、注釈削除用のボタンであるspan.removeで構成されています。
それぞれのデザインについては………あまり詳しく説明する必要ないですよね?
textareaのborder線を描いたり、.markではborder-radius:50%で円形を描いたり、removeではマウス載ったときに赤くしたり、そんな程度です。
JavaScript部分の解説
let note_active = null;
window.addEventListener('load', function () {
addNote(25, 75, 90, 20, 'マイク本体');
addNote(60, 33, 90, 20, 'USBケーブル');
addNote(75, 80, 50, 20, '台座');
});
function addNote(x,y,w,h,comment=null)
{
//注釈生成
let elm = document.createElement('div');
elm.className = 'note';
elm.style.top = y+'%';
elm.style.left = x+'%';
//注釈の「●」(移動モード)を生成
let elm_mark = document.createElement('span');
elm_mark.className = 'mark';
//注釈の「×」(削除ボタン)を生成
let elm_remove = document.createElement('span');
elm_remove.className = 'remove';
elm_remove.textContent = '×';
//注釈の文章エリアを生成
let elm_text = document.createElement('textarea');
elm_text.placeholder = '新しい注釈を入力';
elm_text.value = comment;
elm_text.style.width = w+'px';
elm_text.style.height = h+'px';
//「●」のクリックイベント
elm_mark.onclick = function () {
if (note_active) {
note_active = null;
elm_mark.style.border = '';
} else {
note_active = this.parentNode;
elm_mark.style.border = '2px solid red';
}
}
//「×」のクリックイベント
elm_remove.onclick = function () {
if (confirm('この注釈を削除しても宜しいですか?')) {
this.parentNode.parentNode.removeChild(elm);
}
};
elm.onblur = function () {
note_active = null;
};
//注釈div.noteの中にmark/textarea/removeを追加
elm.appendChild(elm_mark);
elm.appendChild(elm_text);
elm.appendChild(elm_remove);
//注釈div.noteを基点となるnote_baseへ追加
document.getElementById('note_base').appendChild(elm);
}
document.getElementById('note_base').addEventListener('mousemove', function () {
if (note_active) {
event.preventDefault();
event.stopPropagation();
let rect = this.getBoundingClientRect();
let y = event.clientY - rect.top;
let x = event.clientX - rect.left;
let px = x / this.clientWidth * 100;
let py = y / this.clientHeight * 100;
px = Math.round(px*100)/100; //小数点2桁で丸め処理
py = Math.round(py*100)/100;
if (px > 100) px = 100;
if (py > 100) py = 100;
if (px < 0) px = 0;
if (py < 0) py = 0;
note_active.style.left = px+'%';
note_active.style.top = py+'%';
document.getElementById('pos').innerText = 'X:'+px+' Y:'+py;
}
});
document.getElementById('note_base').addEventListener('mouseclick', function () {
if (note_active) {
note_active = null;
}
});
windows.onload
ページのロードが完了した際に
addNote(25, 75, 90, 20, 'マイク本体');
addNote(60, 33, 90, 20, 'USBケーブル');
addNote(75, 80, 50, 20, '台座');
というように、3つの注釈を追加しています。後述しますが、4つの数値はX座標、Y座標、幅、高さです。
画像のサイズが変わっても問題ないよう、数値はすべて0~100%の範囲での相対座標指定となっています。
addNote
注釈を追加する関数。createElementでdiv要素のクラス名noteを、指定されたX座標、Y座標、幅、高さで生成しています。
ほとんどJavaScript内のコメントで解説していますが、移動モード用の「●」ボタンや削除用の「×」ボタン、そしてテキストエリアなども全部ここでcreateElementしています。
人によっては難しそうと思うかも知れないマウス(タップ)イベントですが、実はすごく単純なことしかしていません。
elm_mark.onclick = function () {
//「●」のクリックイベント
if (note_active) {
note_active = null;
elm_mark.style.border = '';
} else {
note_active = this.parentNode;
elm_mark.style.border = '2px solid red';
}
}
移動モード用の「●」がクリックされたとき、グローバル変数のnote_activeに注釈の要素(div.note)をセットしているだけです。
どうせ同時に移動できる注釈は1つだけなので、グローバル変数1つで十分。クリックするたびにnullか注釈要素か切り替えているだけですね。あとついでに「●」の周囲を赤い枠線で囲って見た目にわかりやすくしているだけ。
「×」をクリックした際のクリックイベントに至ってはremoveChildで要素自体を削除しているだけなのでこれも解説不要ですよね。
note_base.onmousemove
基点となるdiv.note_baseのマウス移動イベントで、ここがもっとも大事なところだと思います。event.preventDefault();とevent.stopPropagation();は別記事でも解説した気がしますが、マウス移動中にほかのデフォルトイベントが発生しないようにするコード。
getBoundingClientRect以降がちょっとややこしいのですが、ざっくり説明すると、こんな感じ。
- #note_baseのX座標、Y座標を取得
- マウスのX/Y座標から#note_baseのX/Y座標を引き、相対的なX/Y座標を取得
- #note_baseの幅と高さを100としたときのX/Y座標の相対的な位置(%)を取得
- 相対座標を小数点以下2桁まで丸めた後、note_activeに設定されている注釈要素へ位置を指定
(整数値だと動きがガクガクになりますし、小数点以下の桁数が多すぎると扱いづらいのでとりあえず2桁にしているだけです。3桁でも4桁でも全然構いません。)
マウスのX/Y座標を指定するだけなら単純なんですけど、それじゃレスポンシブデザインで画像の幅が変動したときにおかしな位置になっちゃいますから、相対座標を計算するためにこんなややこしいことをしています。
こうすることでPCでもスマホでも同じように注釈の操作が可能となっているわけですね。
尚、note_baseのonmouseclickイベントについては「●」を移動モード中に「●」から外れた位置をクリックしても、移動モードが解除されるように note_active = null;を実行しています。
まとめ
今回はさすがに長くなり過ぎるので、HTML/CSS/JavaScriptを合わせた全体像の掲載はしませんが、コードは一切端折ってないので、コピペすればそのまま使えるハズ。
Web上で要素をマウス移動するのってなんだか難しそう、と考える人もけっこういるのですが、そんなことはありません。上述したとおり、コードが長くなる原因のほとんどはcreateElementや相対座標の計算で行数が増えているだけで、やっていることは- 「●」がクリックされたら移動モードかどうかを切り替える
- 移動モード中に限り、マウスの移動に合わせてその位置に要素を移動する
これだけ。
昔だとマウスクリック時に移動モードを開始し、マウスを離したら移動モードを終了する、いわゆるドラッグ操作での要素移動とか実装したものですが、いまはPCとスマホ両対応が求められる時代。
となると、PC専用となってしまうドラッグ操作は極力避けるべきかな、と考え、クリックで移動モードを切り替えるような操作方法にしてみました。
インタラクティブなページ…………なんて言い方は死語かな?w ともあれ、注釈以外にもマウスやタップによる要素の自由移動は何かと活用できる場面があると思うので、お役に立てて頂ければ幸いです。