アイコン画像に吹き出しを付けて、会話風にストーリーを進める HTML や CSS の作り方や CSS テンプレートが定期的にバズっているので、これはニーズがあるんじゃないかと思い、決定版を目指してフキダシトークというサービスをリリースしました。吹き出しを表現する CSS フレームワークと、そのフレームワーク向けの HTML+CSS のジェネレータです。このサンプル画像のような HTML が出力できます。
短めのチュートリアルムービーはこちら。
このサービスを作るにあたって、色々なサービス・ライブラリ・API 等を使ったので、Qiita ではその技術的な解説をサンプルコード付きで書いてみたいと思います。
サーバサイド
Google App Engine
ウェブサーバとしては Google App Engine を使用しています。フキダシトークではほぼ全てのロジックがクライアントサイドにあるため、サーバサイドの選択にはさほど大きな意味が無かったのですが、無料でホストしてくれて独自ドメインが使用できるので GAE/py (Google App Engine / Python) を選択しました。
開発環境を構築するまでが、特に Python がデフォルトでインストールされている Mac だと、驚異的に楽で、基本的には SDK をインストールしたらそれで終わり。GUI でスケルトンが生成でき、ローカルでサーバを起動して動作確認可能な状態になります。
app.yaml ファイルに以下のような記述をすることで、指定したディレクトリ以下は単に静的なファイルを返すだけのサーバになるので、仮に動的な部分がなくても GAE を単なるファイルのホスト元として利用するのもアリなんじゃないかと思います。
handlers:
- url: /image
static_dir: image
- url: /css
static_dir: css
- url: /fonts
static_dir: fonts
- url: /js
static_dir: js
Webapp2
Python のウェブフレームワークとしては、GAE 標準の Webapp2 をそのままです。特になんのひねりもなく、サーバサイドでこれといった処理もしておらず、後述の Jinja2 を起動しているだけ、という感じです。
Jinja2
Python のテンプレートエンジンです。これも GAE 標準の Jinja2 をそのまま使っています。単に HTML が生成出来れば良かったので、一番すぐ使えるテンプレートエンジンを選びました。
ただ、一部の文法が後述の JS テンプレートエンジン Handlebars と被って使いにくかったので (そのままだとエスケープの嵐になる)、以下の記述でテンプレート内に変数を埋め込む際の記法をデフォルトの {{variable}}
から [[variable]]
に変更しています。
JINJA_ENVIRONMENT = jinja2.Environment(
loader=jinja2.FileSystemLoader(os.path.dirname(__file__)),
extensions=['jinja2.ext.autoescape'],
autoescape=True,
variable_start_string='[[',
variable_end_string=']]'
)
軽量シンプルなテンプレートで使い勝手は良かったです。HTML 上でモジュール化したい部分も、テンプレート内でマクロを定義することで簡単に書けました。
{% macro btn(name, value, label, checked=false) -%}
<label class="btn btn-primary{% if checked %} active{% endif %}">
<input type="radio" name="[[name]]" value="[[value]]" autocomplete="off"{% if checked %} checked{% endif %}>[[label]]
</label>
{% endmacro %}
<h4 class="ft-zou">デザイン</h4>
<div class="ft-zou">アイコン</div>
<div class="btn-group" data-toggle="buttons">
[[ btn('icon-size', 'l', '大', true) ]]
[[ btn('icon-size', 'm', '中') ]]
[[ btn('icon-size', 's', '小') ]]
[[ btn('icon-size', 'xs', '極小') ]]
</div>
CDN の積極的な利用
GAE の無料プランには、当たり前ですが利用できるディスク容量や帯域幅に制限があるので、なるべくそれを回避するべく、CDN を積極的に利用しました。
jQuery や Bootstrap といった超メジャーどころであれば、公式の CDN があるのでそれを使えば良いのですが、超が付くほどメジャーじゃないライブラリに関しては、cdnjs を使用しています。2016-03-20 現在 1,874 種類ものライブラリをホストしているそうで、特定の目的をもってググって上位に出てくるライブラリであれば、大抵揃っていると思って間違いないです。
<script src="//code.jquery.com/jquery-2.2.2.min.js"></script>
<script src="//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/js/bootstrap.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/bootbox.js/4.4.0/bootbox.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/autosize.js/3.0.15/autosize.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.0.5/handlebars.min.js"></script>
今回 CDN サイトが落ちている場合のフェイルオーバーは実装しませんでした。そもそもこれらの CDN サイトが十分安定していると想定できること、また個人で提供するサービスにそこまでの堅固性を求めていない、というのがその理由です。
ただ、テスト用、あるいはオフラインで開発したいとき用に、パラメータで CDN を無効にして GAE 側からライブラリをロードできるようにはしてあります。
クライアントサイド -- JS 編
jQuery
JS ライブラリとしては、いつもの jQuery です。IE8 以下はよゆーで切ってるのでもちろん Ver.2 系です (実は編集画面については IE9 以降も切っている)。
jQuery 互換軽量ライブラリの、Zepto.js や Minified.js なども検討しましたが、Bootstrap と合わせて使うにあったって、非互換部分が気になったのと、編集画面は PC を対象としていて、パフォーマンスや帯域にそこまでシビアにならなくても良かったので、本家 jQuery にしました。
DOM を激しく変更するアプリなので、いちいち個々のエレメントにイベントハンドラを設定していたら煩雑になりますし、イベントハンドラの数が増えすぎるとパフォーマンスにも悪影響があります。ですので、親エレメントに一つだけイベントハンドラを設定して、実際には子だけでイベント発火させる (ように見えるように jQuery がもろもろ隠蔽している) 下記のパターンを多用しています。
// ul の中で ...
$talkUl.on('click', 'li', function(e) {
// li がクリックされたら ...
}).on('click', '.ft-close', function(e) {
// .ft-close がクリックされたら ...
}).on('click', '.ft-icon', function(e) {
// .ft-icon がクリックされたら ...
});
Handlebars
クライアントサイドで HTML をレンダリングするにあたって、JS のテンプレートライブラリとしては Handlebars を使いました。これもまたシンプル軽量なテンプレートエンジンで、使い勝手も良かったです。
アプリの規模が小さく、また個人で一気に開発したかったので、HTML に関わるものを1つのファイルに押し込めて管理しようと思って、テンプレートそのものについても script
エレメントで直接 HTML 内に持たせる形で使っています。
<script id="alert-template" type="text/x-handlebars-template">
<div class="alert alert-{{type}} alert-dismissible" role="alert">
<button type="button" class="close" data-dismiss="alert"><span>×</span></button>
{{text}}
</div>
</script>
var templates = {
alert: Handlebars.compile($('#alert-template').html()),
character: Handlebars.compile($('#character-template').html()),
icon: Handlebars.compile($('#icon-template').html()),
baloon: Handlebars.compile($('#baloon-template').html())
};
var addAlert = function(type, text) {
var html = templates.alert({
type: type, text: text
});
$alert.append(html);
};
Autosize
ユーザの入力に合わせてテキストエリアのサイズをフィットさせたかったので、そのために Autosize を使っています。ググってみると古い記事で「日本語だと上手く動かない」とか「jQuery のプラグインとして動作させている例」などが見つかりますが、最新版はスタンドアロンで動作し、日本語も問題ありません。
Autosize のイケてるところは、たとえスタンドアロンとはいえ、生 DOM ではなく jQuery オブジェクトを渡してもちゃんと判別して動作してくれるところですね。
autosize($li.find('textarea'));
クライアントサイド -- HTML5 編
HTML5 と言っていますがいわゆる広義の HTML5 です。HTML5 関連の比較的新しい JS API、あるいは単にその頃流行りだした API なども含みます。
Drag & Drop API と File API
ローカルにある画像ファイルをドラッグ&ドロップで受け取って操作する際に使っている API です。意図したとおりにドラッグ&ドロップを動作させるためにはまず、ドラッグ中あるいはドロップした際のブラウザデフォルト挙動をキャンセルする必要があります。そしてファイルがドロップされた時は、そのファイルの情報を読み取って使用します。
var stopEvent = function(e) {
e.preventDefault();
e.stopPropagation();
};
var onDrop = function(e, fileHandler) {
stopEvent(e);
// File オブジェクトを取得
var files = e.originalEvent.dataTransfer.files;
for (var i = 0; i < files.length; i++) {
var file = files[i];
// ファイルが画像ファイルかどうかをチェック
if (!isAcceptableType(file.type)) {
addAlert('warning', file.name + ' は対応可能な画像形式ではありません。png/jpeg/gif のいずれかを指定して下さい。')
continue;
}
// 実際にファイルを処理
fileHandler(file);
}
};
$('#ft-add-character')
.on('dragenter dragover dragleave', stopEvent)
.on('drop', function(e) {
onDrop(e, addCharacter);
});
canvas エレメント と File API
canvas に何か書いてそれを表示させる、というような通常の目的では使っていません。フキダシトークに登録するアイコン画像を 256 x 256 にまで縮小するために、画面上では見えない canvas に対して画像を描画し、Data URI スキーマにエンコードされた状態で縮小した画像を取得しています。
var canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
var ctx = canvas.getContext('2d');
// 初期状態のキャンバスは全てのピクセルが rgba(0,0,0,0) 相当なのでその状態を取得しておく。
var nullImageData = ctx.createImageData(256, 256);
var addIcon = function($tr, file) {
var fileReader = new FileReader();
$(fileReader).on('load', function(e) { // File オブジェクト読み込み完了時
// ネガティブマージンですっ飛ばして、画面上には見えない img エレメントを生成。
// display: none は画像サイズが取得出来ないので使えない。
var $img = $('<img>').addClass('ft-loading-image');
$img.on('load', function() { // オリジナル画像ロード完了時
// オリジナル画像のサイズを取得
var w = $img.width();
var h = $img.height();
// アスペクト比を保ったまま上下左右中央、256 x 256 以内に縮小するため、
// 縮小後の座標とサイズを計算
var target = {x: 0, y: 0, w: 0, h: 0};
if (w > h) {
target.w = 256;
target.h = (256 / w) * h;
target.y = 128 - target.h / 2;
} else {
target.h = 256;
target.w = (256 / h) * w;
target.x = 128 - target.w / 2;
}
// canvas は繰り返し利用するため、初期状態に戻す
ctx.putImageData(nullImageData, 0, 0);
// オリジナル画像を縮小しながら canvas に描画
ctx.drawImage($img.get(0), 0, 0, w, h, target.x, target.y, target.w, target.h);
$img.remove();
var imageData = canvas.toDataURL();
// canvas から縮小画像の Data URI を取得してさらに処理する
addIcon_($tr, imageData);
});
$body.append($img);
// ここで初めてオリジナルの画像が描画される
$img.attr('src', e.target.result);
});
// ドロップ時に取得した file オブジェクトを Data URI として読み込む
fileReader.readAsDataURL(file);
};
localStorage
登録されたアイコン画像や設定されたデザインの状態などは全て localStorage に保存しています。localStorage には単純な文字列しか保存できないのと、ロード時に何もなくてもデフォルト値を返すようにしたかったので、下記のような簡単なヘルパーを書いて使っています。
var save = function(key, value) {
localStorage.setItem(key, JSON.stringify(value));
};
var load = function(key, defaultValue) {
var value = localStorage.getItem(key);
if (!value) {
return defaultValue;
}
return JSON.parse(value);
};
var remove = function(key) {
localStorage.removeItem(key);
};
Selection API と execCommand
最終的に HTML と CSS を出力した際、出力結果全てがあらかじめ選択状態だと使い勝手が良いですし、その内容をクリップボードにコピーするボタンも付けたかったので、Selection API と execCommand を使いました。
execCommand はセキュリティ上、クリックなどのユーザアクションがトリガーの時しか動作しないはずですので、その点は注意が必要です。document のロード時に勝手に何か悪い事をする、ような目的には使えないと言うことです。
var selectPre = function() {
var $pre = $exportModal.find('pre');
// pre エレメントの中身全てを選択している事を表す Range オブジェクト
var range = document.createRange();
range.selectNodeContents($pre.get(0));
// 現在の選択状態をクリア
var selection = window.getSelection();
selection.removeAllRanges();
// 選択範囲を設定
selection.addRange(range);
};
$exportModal.on('click', '#ft-copy', function() {
selectPre();
try {
document.execCommand("copy")
} catch (e) { // 非対応ブラウザ向け
addAlert('warning', 'コピー失敗');
}
});
クライアントサイド -- CSS + JS 編
Bootstrap
エンジニアの福音、何となくそれらしいサイトが簡単に書ける CSS フレームワークの雄、Bootstrapをご多分に漏れず使っています。
Bootswatch
…とは言え、Bootstrap のデフォルトのデザインのままだと、あまりにも Bootstrap らしさが前面に出てしまうので、お洒落な Bootstrap のテーマが詰まった Bootswatch の中から、Paper を選びました。
Bootbox
ブラウザネイティブ実装の confirm の代わりになるものとして、Bootstrap 用の Bootbox というライブラリを使っています。
オリジナルの confirm と違って非同期で動作するので、その点には注意が必要です。prompt もこっちに置き換えようと思っていたのですが、非同期に書くのが面倒な場所だったので保留にしてます。いずれ直す (直さない)。
if ($icons.length > 0) {
bootbox.confirm('アイコンを使用している吹き出しも同時に削除されます。よろしいですか?', function(result) {
if (result) {
deleteIcon_($span, $icons, iconClass);
}
});
}
WebFont
サイトのロゴ部分、あるいはいくつかのヘッドライン部分を飾る目的で WebFont を使っています。カタカナのみのフリーフォントゾウを使わせてもらいました。カタカナのみだと容量もかなり小さいので読み込み時間も気になりませんね。
WebFont の使い方を調べると、色んなフォントファイルを用意してどんなブラウザでも可能な限りちゃんと表示させる方法が見つかりますが、今どき相手にすればいいブラウザはもうみんな WOFF サポートしているので、それだけでいいんじゃないかと思います。
@font-face {
font-family: 'Zou';
src: url('../fonts/zou-regular.woff') format('woff');
}
.ft-zou {
font-family: 'Zou';
letter-spacing: 0.05em;
}
レスポンシブ YouTube プレイヤー
ヘルプ内に YouTube の動画を埋め込む際、動画プレイヤーのサイズも他のエレメントと同様レスポンシブにサイズが変化するようにしました。…といっても何も難しいことはなく、iframe のサイズを変更すると YouTube 側でいい感じにしてくれます。
@media (max-width: 992px) {
.ft-help #ft-tutorial-player {
width: 532px;
height: 300px;
}
}
@media (max-width: 768px) {
.ft-help #ft-tutorial-player {
width: 90%;
}
}
ソースコード
フキダシトークで使用している全てのソースコードは github 上で公開しています。MIT ライセンスですので、もし興味があれば色々ご自由にご利用下さい。
最後に
このくらいの内容のウェブアプリであれば、上記に挙げたような様々なサービス・ライブラリ・技術を使う事で、一人だけの力で、お金もかけず、余暇だけを使い、さくっと作れる今の時代がこの上なく楽しいですね。