ICSPM22のアルバムページ
ICSPM22 のアルバムページを作るときにやったこと†
最近 ICSPM22 という学会がありまして、そのホームページ作成を担当しました。
そのアルバムページがこれなのですが、
http://dora.bk.tsukuba.ac.jp/event/ICSPM22/photo
PC からでもスマホからでも見られるようにするため、
- 画面が小さくても写真を見られる
- 写真をめくるのにフリックが使える
といったあたりを工夫しています。
このページを作る際に、いろいろ調べたことをまとめます。
前提†
このページは Ruby on Rails で作られていて、Gemfile には
- gem 'haml-rails'
- gem 'coffee-rails'
が含まれています。
要件としては、
- 画面サイズが小さい場合にも集合写真がはみ出さないようにサイズを調整する
- 画面サイズが小さい場合にもスナップ写真のサムネイルがはみ出さないように 横幅が変わる(リキッドデザイン)
- サムネイルは数が多いので css スプライトを使って処理を軽くする
- サムネイルなどの縮小写真はオリジナルの画像からコントローラにて自動生成する
- サムネイルをクリックすると全画面でスライドショー
- ブラウザの戻るボタンで全画面終了
- 画像のフリックで写真をめくれる
- 画面サイズが小さい場合にも写真がはみ出さないようサイズを調整する
といったあたりです。
写真ファイルの置き場†
まず、写真ファイルを次の場所に置きました。
集合写真は
public/images/photo/icspm22_photo.jpg
public/images/photo/icspm22_photo_small.jpg
スナップ写真は
public/images/photo/(folder名)/(写真名).jpg
LANG:console $ ls public/images/photo/ Dec11 Dec12 Dec12_Banquet Dec13 icspm22_photo.jpg icspm22_photo_small.jpg
コントローラの作成&ルート設定†
今回作る photo コントローラは photo#index という1つのページしか持ちません。
LANG:console $ rails generate controller photo index create app/controllers/photo_controller.rb invoke haml create app/views/photo create app/views/photo/index.html.haml invoke test_unit create test/controllers/photo_controller_test.rb invoke helper create app/helpers/photo_helper.rb invoke test_unit create test/helpers/photo_helper_test.rb invoke assets invoke coffee create app/assets/javascripts/photo.js.coffee invoke less create app/assets/stylesheets/photo.css.less $ jed config/routes.rb scope "(:locale)", locale: locale_regexp do ... get 'photo' => 'photo#index' ... $ rails s
ここまでで、
http://localhost:3000/event/ICSPM22/photo
へのアクセスによりデフォルトの表示を確認できました。
Photo#index
Find me in app/views/photo/index.html.haml
集合写真を表示する†
app/controllers/photo_controller.rb
LANG:ruby class PhotoController < ApplicationController def index @photo_root = '/event/ICSPM22/images/photo' end end
app/views/photo/index.html.haml
LANG:haml - content_for :title do Photo .photo -# =========================== conference photo %h1 Conference Photo = link_to "#{@photo_root}/icspm22_photo.jpg", target: '_blank' do = image_tag "#{@photo_root}/icspm22_photo_small.jpg", id: 'conference_photo'
集合写真をクリックすると拡大表示するところまでOKです。
全体を .photo で囲っているのはアプリケーション全体で結合された css から要素を参照できるようにするためです。次項を参照。
写真のサイズ調整†
そのままだと画面サイズの小さいスマホなどからアクセスしたときに 写真が画面からはみ出てしまうので、写真が画面の横幅よりも大きくならない ように調整します。
app/assets/javascript/photo.js.less LANG:less
.photo { // =========================== conference photo img#conference_photo { width: 100%; max-width: 753px; } }
rails ではデフォルトで、すべてのコントローラの css が1つのファイルに 結合されていっぺんに読み込まれるため、.photo という div で全体を括っておき、 目的の要素以外に影響を及ぼさないようにしています。
スナップ写真の一覧を作成する†
public/images/photo/*/*.jpg に置いた元データから
- サムネール
- スライドショー
- 拡大画像
の3サイズの画像を自動生成します。
これには rmagick というライブラリを使います。
LANG:console $ jed Gemfile ... gem 'rmagick', '2.13.2' $ bin/bundle install
app/controller/photo_controller.rb
LANG:ruby #coding: utf-8 require 'rubygems' require 'RMagick' class PhotoController < ApplicationController def index # 画像ファイルの url @photo_root = '/event/ICSPM22/images/photo' # 画像ファイルのファイルパスとサブフォルダ名 @photo_folder = 'public/images/photo/' @folders = ['Dec11', 'Dec12', 'Dec12_Banquet', 'Dec13'] # 画像一覧の作成 @images = [] @folders.each do |folder| @images << [folder, enum_images(@photo_folder+folder)] end end def enum_images(folder) json_index = "#{folder}/index2.json" # 一覧を作成済みならそれを読んで返す if File.exists? json_index return JSON.restore File.read(json_index) end # 画像サイズ一覧 thumbnail_height = 60.0 small_image_height = 480.0 large_image_limit = 1000 # 画像一覧及び縮小画像を作成する thumb_folder = confirm_existence_of_folder "#{folder}/thumb" small_folder = confirm_existence_of_folder "#{folder}/small" large_folder = confirm_existence_of_folder "#{folder}/large" # 画像サイズを収集し、縮小画像を作る data = [] image_files_in_folder(folder).each do |file| image = Magick::Image.read("#{folder}/#{file}").first data << [file, image.columns, image.rows] prepare_small_image(image, "#{thumb_folder}/#{file}", thumbnail_height/image.rows) prepare_small_image(image, "#{small_folder}/#{file}", small_image_height/image.rows) prepare_small_image(image, "#{large_folder}/#{file}", 1.0/[1, image.rows/large_image_limit].max) end # インデックスを保存 open(json_index, "w") do |f| f.write JSON.dump(data) end # 使用済み画像データを開放する run_gc data end # フォルダが存在しなければ作成する(1段階) # 与えられたフォルダ名を返す def confirm_existence_of_folder(folder) Dir.mkdir folder unless Dir.exists? folder folder end # 与えられたフォルダにある画像ファイル名を # 古い物から順に並べて返す def image_files_in_folder(folder) `ls -tr #{folder}`. split(/\n/). select{ |file| file=~/\.(jpg|JPG)$/ } end # image の縮小画像を指定のスケールで作成する def prepare_small_image(image, file, scale) image.resize(scale).write(file) unless File.exists? file end # ガーベージコレクションを走らせて、不要メモリを回収する def run_gc fDisabled = GC.enable GC.start GC.disable if fDisabled end end
app/views/photo/index.html.haml
LANG:haml -#=========================== image list :javascript (function(){ var images = #{JSON.dump @images}; var photo_root = "#{@photo_root}"; var photo = new Photo(photo_root, images); photo.outputFolderIndex(); photo.outputFolders(); })();
app/assets/javascript/photo.js.coffee
LANG:coffeescript root = exports ? @ class root.Photo constructor: (@photo_root, @images)-> # フォルダ一覧を出力する outputFolderIndex: -> src = [] src.push '<h1>Snaps</h1>' src.push '<ul class="nav nav-pills nav-stacked">' for folder_entry in @images folder = folder_entry[0] src.push "<li><a href=\"##{folder}\">#{@humanizeFolder(folder)}</a></li>" src.push '</ul>' document.write src.join('\n') # 指定フォルダの画像一覧を出力する outputFolder: (i)-> [folder, images] = @images[i] src = [] src.push "<h1>#{@humanizeFolder(folder)}</h1>" src.push '<div class="images">' for image in images src.push "<div>" + "<img src=\"#{@photo_root}/#{folder}/thumb/#{image[0]}\" />" + "</div>" src.push '</div>' document.write src.join('\n') # すべてのフォルダの画像一覧を出力する outputFolders: -> for i in [0..(@images.length-1)] @outputFolder(i) # folder 名の '_' を ' ' に変えて読みやすくする humanizeFolder: (folder)-> folder.replace(/_/, ' ')
outputFolderIndex は bootstrap の nav-pills を使って フォルダ一覧を出力します。
一旦配列に溜めてからまとめて join しているのは、 恐らくその方が実行効率がよいのではないかという配慮です。 http://koshian.hatenablog.com/entry/2014/10/09/190955
app/assets/stylesheets/photo.css.less
// =========================== thumbnails .images:after { // clearfix content: "."; display: block; height: 0; font-size:0; clear: both; visibility:hidden; } .images div { float: left; border: solid white 2px; }
サムネール画像は div.images の下の div の中に入ります。
様々な画面サイズのデバイスに対応するために、 float: left で Google:リキッドレイアウト にしていますが、 そのままだと次のフォルダの前で改行してくれないので、 div.images に clearfix を施しています。Google:clearfix
サムネールを css スプライトを使って表示する†
件のページではサムネール画像の数が多いので、 上記のように普通に表示するとネットワークへの負荷が高まり、 レスポンスが低下してしまいます。
そこで、サムネール画像を複数横に繋げたファイルを1つ用意することで 画像のダウンロード回数を1回に減らし、 各 div にはそれを切り分けて表示することにします。 これは Google:css スプライト と呼ばれる手法です。
まず、コントローラ側でスプライト画像を作成します。
app/controller/photo_controller.rb の enum_images(folder) を以下のように変更します。
- サムネール画像を thumbs リストに保存しておき
- sprite という画像に結合して保存
LANG:ruby # 画像サイズを収集し、縮小画像を作る thumbs = Magick::ImageList.new # サムネール画像のリスト data = [] # 画像データ image_files_in_folder(folder).each do |file| image = Magick::Image.read("#{folder}/#{file}").first data << [file, image.columns, image.rows] thumb = prepare_small_image(image, "#{thumb_folder}/#{file}", thumbnail_height/image.rows) || Magick::Image.read("#{thumb_folder}/#{file}").first # すでにあれば読み込む small = prepare_small_image(image, "#{small_folder}/#{file}", small_image_height/image.rows) large = prepare_small_image(image, "#{large_folder}/#{file}", 1.0/[1, image.rows/large_image_limit].max) thumbs << thumb # サムネール画像をリストに追加 end # サムネールのスプライト画像を作成&保存 columns = thumbs.inject(0){ |sum, thumb| sum + thumb.columns } sprite = Magick::Image.new( columns, thumbs[0].rows ) thumbs.inject(0){ |offset, thumb| sprite.composite!(thumb, offset, 0, Magick::CopyCompositeOp) offset + thumb.columns } sprite.write("#{thumb_folder}/_sprite.jpg")
その際、prepare_small_image も以下のように変更しました。
- 新たに作成したならその画像を返す
- すでに存在したなら nil を返す
LANG:ruby # image に与えられた画像を scale で縮小した画像を作成する def prepare_small_image(image, file, scale) unless File.exists? file small = image.resize(scale) small.write(file) small else nil end end
そして表示では、
app/assets/javascript/photo.js.coffee
LANG:coffeescript offset = 0 sprite = "#{@photo_root}/#{folder}/thumb/_sprite.jpg" for image, j in images width = Math.round(image[1] * 60.0 / image[2]) style = "width:#{width}px;" + "background:url("#{sprite}") -#{offset}px 0px" id = "#{i}-#{j}" src.push "<div style=\"#{style}\" id=\"#{id}\"></div>" offset += width
のように、スプライト画像を背景に設定した空の div を並べます。 これで css で高さを指定すれば、一見すると多数の画像が並んでいるように見えることになります。
後で画像をクリックできるようにするため、cursor: pointer も追加しておきます。
LANG:less .images div { float: left; border: solid white 2px; height: 60px; cursor: pointer; }
サムネールのクリックでフルスクリーン表示する†
フルスクリーン表示部分の haml コード
app/views/photo.html.haml
LANG:haml -#=========================== full screen controls #full_screen #image_frame %img#image #controls %a#back{href: 'javascript:close_image()', title: 'close image'} %i.fa{class: 'fa-reply'} %a#prev{href: 'javascript:prev_image()', title: 'prev image'} %i.fa{class: 'fa-arrow-left'} %a#next{href: 'javascript:next_image()', title: 'next image'} %i.fa{class: 'fa-arrow-right'} %a#large{href: '#', target: '_blank', title: 'enlarge image'} %i.fa{class: 'fa-external-link'}
app/assets/photo.css.less
LANG:less // 全画面画像表示用スクリーン #full_screen { display: none; position: fixed; top: 0px; left: 0px; bottom: 0px; right: 0px; z-index: 2000; // 1050 以下だと navbar に隠れる background: rgba(0,0,0,0.9); // 少し透ける // 画像を含む部分 #image_frame { position: absolute; left: 0px; top: 0px; width: 100%; height: 90%; overflow: hidden; text-align: center; // 中に含まれる画像を上下方向に中央に配置するための hack // http://blog.3streamer.net/html5-css3/css-box-be-centerd-483/ &:before { content: ""; display: inline-block; height: 100%; vertical-align: middle; margin-right: -0.25em; } // 画像は親要素のサイズを越えないよう必要に応じて縮小される img#image { display: inline-block; vertical-align:middle; max-width: 96%; // 100% だとうまく行かない場合がある? max-height: 100%; } } // ボタンを含む部分 #controls { position: absolute; left: 0px; bottom: 0px; width: 100%; height: 10%; text-align: center; // ボタン a { display: inline-block; width: 15%; height: 100%; font-size: 7vh; &:link, &:visited { color: grey; text-decoration: none; } &:hover { color: white; text-decoration: none; } } } }
img#image の max-width が 100% でなく 96% になっているのは、 なぜか production 環境のみで表示が崩れるため。理由はよく調べていない。
html からイベントのバインドを要請
LANG:javascript var photo = new Photo(photo_root, images); photo.outputFolderIndex(); photo.outputFolders(); $(function(){ photo.registerEvents(); });
javascript でイベント処理
- サムネールをクリックしたら全画面で画像を表示
LANG:coffeescript # イベントハンドラの割り当て registerEvents: ()-> $('.photo .images div').bind 'click', (e)=> [folder_id, image_id] = e.target.id.split('-') @openImage(folder_id, image_id) $(window).bind 'resize', (e)=> @resizeComponents() openImage: (folder_id, image_id)-> @folder_id = parseInt folder_id @image_id = parseInt image_id @folder = @images[@folder_id][0] @image = @images[@folder_id][1][@image_id] @resizeComponents() $('.photo #image').attr('src', "#{@photo_root}/#{@folder}/small/#{@image[0]}") $('.photo #large').attr('href', "#{@photo_root}/#{@folder}/large/#{@image[0]}") $('.photo #full_screen').show() resizeComponents: ()-> // android ブラウザで font-size を vh 単位で指定できない(?) ため $("#controls a").css('font-size', Math.round(window.innerHeight*7/100) + "px");
画像を開く際、#large ボタンの飛び先を拡大画像のアドレスに設定しています。
また、resizeComponents にてボタンのフォントサイズを設定しています。
本当は font-size: 7vh; という css の設定だけで良いはずなのですが、 android のブラウザではこれがうまくいかなかったため、 仕方無く javascript で値を計算してピクセル単位で設定しています。
画面サイズが変わったときに、フォントサイズを変更するため、resize イベントも割り当てています。
全画面表示を解除する†
a#back を押された時に全画面表示を解除するのは $('#full_screen').hide() だけで簡単にできます。
LANG:coffeescript registerEvents: ()-> ... $('.photo a#back').bind 'click', (e)-> false $('.photo a#back').bind 'mousedown', (e)=> @closeImage() closeImage: -> $('#full_screen').hide()
「戻る」ボタンへの対応†
上記のままだと、全画面表示の状態でブラウザの「戻る」ボタンを押されたときに、 全画面表示を解除するだけでなく、photo のページのさらに前のページに戻ってしまいます。
ユーザーからすると全画面表示を終了して画像一覧ページに戻りたいところではないでしょうか?
これを実現するには google:html5 history API というのを使えば良いようです。
まず openImage にて、全画面表示時に
LANG:coffeescript window.location.hash = "browsing";
します。これにより、ブラウザのアドレスバーに表示されるアドレスに #browsing というハッシュが付き、あたかもページを移動したかのような履歴が残ります。
つまり、ここでブラウザの「戻る」ボタンを押すと #browsing が付く前のアドレスに移動します。 この移動は、window の popstate イベントを発火させるため、
LANG:coffeescript registerEvents: ()-> ... $(window).bind 'popstate', (e)=> if (window.location.hash != "#browsing") and $("#image_frame").is(':visible') @closeImage()
のようにして、全画面表示を終わらせます。
closeImage() はボタンからも呼ばれるので、location.hash に #browsing が付いていれば 履歴を1つ遡ることにします。
LANG:coffeescript closeImage: -> $('#full_screen').hide() if (window.location.hash == "#browsing") window.history.back()
以上でブラウザの「戻る」ボタンで全画面表示を終了できるようになりました。
prev / next ボタンで画像をめくる†
難しいところはありません。
画像番号を1つずつ増やしたり減らしたりして openImage を呼ぶだけです。
ボタンが押されたら背景をグレーにして、ユーザーにフィードバックします。
先頭や末尾まで来たらボタンを赤くして、それ以上画像がないことを知らせます。
後で使うので、prevImage, nextImage は移動したか、しなかったかを論理値で返します。
registerEvents: ()-> ... $('.photo a#next').bind 'click', (e)-> false $('.photo a#next').bind 'mousedown', (e)=> @nextImage() false $('.photo a#prev').bind 'click', (e)-> false $('.photo a#prev').bind 'mousedown', (e)=> @prevImage() false prevImage: ()-> if @image_id > 0 @openImage(@folder_id, @image_id - 1) else if @folder_id > 0 @openImage(@folder_id - 1, @images[@folder_id - 1][1].length - 1) else @flashButton $(".photo #prev"), 'red' return false @flashButton $(".photo #prev"), '#333' true nextImage: ()-> if @image_id < @images[@folder_id][1].length - 1 @openImage(@folder_id, @image_id + 1) else if @folder_id < @images.length - 1 @openImage(@folder_id + 1, 0) else @flashButton $(".photo #next"), 'red' return false @flashButton $(".photo #next"), '#333' true // ボタンを一定時間、指定の色で光らせます flashButton: (button)-> button.css('background', 'red') .delay(300) .queue -> $(@).css('background', '') .dequeue()
画面のフリックで写真をめくる†
スマホだと、ボタンを押して移動というのはなじまないので、 画面を横にフリックすることで写真をめくれるようにします。
それに合わせて、動作確認のしやすさなどもあるので、 マウスでもフリックできるようにしておきます。
マウスによる画面のドラッグは、mousedown / mousemove / mouseup で、
タッチによるフリックは touchstart / touchmove / touchend / touchcancel で、
それぞれ検出します。
ほとんど同じ動作になるため、イベントハンドラは共有しています。
唯一異なるのはイベントオブジェクトから マウス/指 の位置を取り出す部分なのですが、 その部分は @pageX(e) に切り出してあります。
タッチの時は e.originalEvent.changedTouches[0].pageX に、
マウスの時は e.pageX に、それぞれ座標が入っています。
ドラッグ動作は次の通りです。
- dragStart でドラッグ開始座標を @drag.start_x に保存しておく
- dragMove で #image_frame を左右に動かす
- dragEnd で #image_frame の位置に応じて
- ある程度以上動かしていれば prevImage / nextImage のどちらかを呼ぶ
- あまり動かしていなければ #image_frame の位置を初期位置まで戻す
- まったく動かしていなければ closeImage を呼ぶ
- dragCancel では #image_frame の位置を初期位置まで戻す
dragStart は #image_frame に割り当てられていますが、
dragMove, dragEnd, dragCancel は document に割り当てられています。
このようにすることで、マウスが #image_frame の外に出てもイベントを追えるようになります。
LANG:coffeescript registerEvents: ()-> ... $('.photo #image_frame').bind 'mousedown touchstart', (e)=> @dragStart(e) $(document).bind 'mousemove touchmove', (e)=> @dragMove(e) $(document).bind 'mouseup touchend', (e)=> @dragEnd(e) $(document).bind 'touchcancel', (e)=> @dragCancel(e) dragStart: (e)-> return true if @drag @drag = { start_x: @pageX(e) } e.originalEvent.preventDefault() e.originalEvent.stopPropagation() false dragMove: (e)-> return true unless @drag image_frame = $("#image_frame") image_frame.css("left", ( @pageX(e) - @drag.start_x ) + 'px'); e.originalEvent.preventDefault() e.originalEvent.stopPropagation() if Math.abs(image_frame.position().left) > 5 @drag.not_to_close = true false dragEnd: (e)-> return true unless @drag image_frame = $("#image_frame") if Math.abs(image_frame.position().left) > window.innerWidth * 15 / 100 image = $('#image') image.css('opacity', 0) image.animate({opacity: 1}, 300) if image_frame.position().left > 0 result = @prevImage() else result = @nextImage() if result image_frame.css('left', '0px') else image_frame.animate({left: '0px'}, 200) else if @drag.not_to_close image_frame.animate({left: '0px'}, 200) else @closeImage() image_frame.css('left', '0px') e.originalEvent.preventDefault() e.originalEvent.stopPropagation() @drag = null false dragCancel: (e)-> return true unless @drag $("#image_frame").animate({left: '0px'}, 200) e.originalEvent.preventDefault() e.originalEvent.stopPropagation() @drag = null false pageX: (e)-> if e.originalEvent.changedTouches e.originalEvent.changedTouches[0].pageX else e.pageX
キーボード操作†
次の動作を実現します
- ESC あるいは Back Space で全画面表示の終了
- Left / Right で画像をめくる
LANG:coffeescript $('body').bind 'keydown', (e)=> return true unless $("#image_frame").is(':visible') switch e.keyCode when 27 # Esc @closeImage() false when 37 # ArrowLeft @prevImage() false when 39 # ArrowRight @nextImage() false when 8 # BackSpace @closeImage() false else true
ソースコード†
以上のコードをまとめて掲載しておきます。
app/constollers/photo_controller.rb†
LANG:ruby(linenumber) #coding: utf-8 require 'rubygems' require 'RMagick' class PhotoController < ApplicationController def index # 画像ファイルの url @photo_root = '/event/ICSPM22/images/photo' # 画像ファイルのファイルパスとサブフォルダ名 @photo_folder = 'public/images/photo/' @folders = ['Dec11', 'Dec12', 'Dec12_Banquet', 'Dec13'] # 画像一覧の作成 @images = [] @folders.each do |folder| @images << [folder, enum_images(@photo_folder+folder)] end end def enum_images(folder) json_index = "#{folder}/index2.json" # 一覧を作成済みならそれを読んで返す if File.exists? json_index return JSON.restore File.read(json_index) end # 画像サイズ一覧 thumbnail_height = 60.0 small_image_height = 480.0 large_image_limit = 1000 # 画像一覧及び縮小画像を作成する thumb_folder = confirm_existence_of_folder "#{folder}/thumb" small_folder = confirm_existence_of_folder "#{folder}/small" large_folder = confirm_existence_of_folder "#{folder}/large" # 画像サイズを収集し、縮小画像を作る thumbs = Magick::ImageList.new # サムネール画像のリスト data = [] # 画像データ image_files_in_folder(folder).each do |file| image = Magick::Image.read("#{folder}/#{file}").first data << [file, image.columns, image.rows] thumb = prepare_small_image(image, "#{thumb_folder}/#{file}", thumbnail_height/image.rows) || Magick::Image.read("#{thumb_folder}/#{file}").first # すでにあれば読み込む small = prepare_small_image(image, "#{small_folder}/#{file}", small_image_height/image.rows) large = prepare_small_image(image, "#{large_folder}/#{file}", 1.0/[1, image.rows/large_image_limit].max) thumbs << thumb # サムネール画像をリストに追加 end # サムネールのスプライト画像を作成&保存 columns = thumbs.inject(0){ |sum, thumb| sum + thumb.columns } sprite = Magick::Image.new( columns, thumbs[0].rows ) thumbs.inject(0){ |offset, thumb| sprite.composite!(thumb, offset, 0, Magick::CopyCompositeOp) offset + thumb.columns } sprite.write("#{thumb_folder}/_sprite.jpg") # インデックスを保存 open(json_index, "w") do |f| f.write JSON.dump(data) end # 使用済み画像データを開放する run_gc data end # フォルダが存在しなければ作成する(1段階) # 与えられたフォルダ名を返す def confirm_existence_of_folder(folder) Dir.mkdir folder unless Dir.exists? folder folder end # 与えられたフォルダにある画像ファイル名を # 古い物から順に並べて返す def image_files_in_folder(folder) `ls -tr #{folder}`. split(/\n/). select{ |file| file=~/\.(jpg|JPG)$/ } end # image に与えられた画像を scale で縮小した画像を作成する def prepare_small_image(image, file, scale) unless File.exists? file small = image.resize(scale) small.write(file) small else nil end end # ガーベージコレクションを走らせて、不要メモリを回収する def run_gc fDisabled = GC.enable GC.start GC.disable if fDisabled end end
app/views/photo/index.html.haml†
LANG:haml(linenumber) -content_for :title do Photo .photo -#=========================== conference photo %h1 Conference Photo = link_to "#{@photo_root}/icspm22_photo.jpg", target: '_blank' do = image_tag "#{@photo_root}/icspm22_photo_small.jpg", id: 'conference_photo' -#=========================== full screen controls #full_screen #image_frame %img#image #controls %a#back{href: 'javascript:close_image()', title: 'close image'} %i.fa{class: 'fa-reply'} %a#prev{href: 'javascript:prev_image()', title: 'prev image'} %i.fa{class: 'fa-arrow-left'} %a#next{href: 'javascript:next_image()', title: 'next image'} %i.fa{class: 'fa-arrow-right'} %a#large{href: '#', target: '_blank', title: 'enlarge image'} %i.fa{class: 'fa-external-link'} -#=========================== image list :javascript (function(){ var images = #{JSON.dump @images}; var photo_root = "#{@photo_root}"; var photo = new Photo(photo_root, images); photo.outputFolderIndex(); photo.outputFolders(); $(function(){ photo.registerEvents(); }); })();
app/assets/stylesheets/photo.css.less†
LANG:less(linenumber) // Place all the styles related to the photo controller here. // They will automatically be included in application.css. // You can use Less here: http://lesscss.org/ .photo { // =========================== conference photo img#conference_photo { width: 100%; max-width: 753px; } // =========================== thumbnails .images:after { // clearfix content: "."; display: block; height: 0; font-size:0; clear: both; visibility:hidden; } .images div { float: left; border: solid white 2px; height: 60px; cursor: pointer; } // 全画面画像表示用スクリーン #full_screen { display: none; position: fixed; top: 0px; left: 0px; bottom: 0px; right: 0px; z-index: 2000; // 1050 以下だと navbar に隠れる background: rgba(0,0,0,0.9); // 少し透ける // 画像を含む部分 #image_frame { position: absolute; left: 0px; top: 0px; width: 100%; height: 90%; overflow: hidden; text-align: center; // 中に含まれる画像を上下方向に中央に配置するための hack // http://blog.3streamer.net/html5-css3/css-box-be-centerd-483/ &:before { content: ""; display: inline-block; height: 100%; vertical-align: middle; margin-right: -0.25em; } // 画像は親要素のサイズを越えないよう必要に応じて縮小される img#image { display: inline-block; vertical-align: middle; max-width: 96%; max-height: 100%; } } // ボタンを含む部分 #controls { position: absolute; left: 0px; bottom: 0px; width: 100%; height: 10%; text-align: center; // ボタン a { display: inline-block; width: 15%; height: 100%; font-size: 7vh; &:link, &:visited { color: grey; text-decoration: none; } &:hover { color: white; text-decoration: none; } } } } }
app/assets/javascripts/photo.js.coffee†
LANG:coffeescript(linenumber) # Place all the behaviors and hooks related to the matching controller here. # All this logic will automatically be available in application.js. # You can use CoffeeScript in this file: http://coffeescript.org/ root = exports ? @ class root.Photo constructor: (@photo_root, @images)-> # フォルダ一覧を出力する outputFolderIndex: -> src = [] src.push '<h1>Snaps</h1>' src.push '<ul class="nav nav-pills nav-stacked">' for folder_entry in @images folder = folder_entry[0] src.push "<li><a href=\"##{folder}\">#{@humanizeFolder(folder)}</a></li>" src.push '</ul>' document.write src.join('\n') # 指定フォルダの画像一覧を出力する outputFolder: (i)-> [folder, images] = @images[i] src = [] src.push "<h1>#{@humanizeFolder(folder)}</h1>" src.push '<div class="images">' offset = 0 sprite = "#{@photo_root}/#{folder}/thumb/_sprite.jpg" for image, j in images width = Math.round(image[1] * 60.0 / image[2]) style = "width:#{width}px;" + "background:url("#{sprite}") -#{offset}px 0px" id = "#{i}-#{j}" src.push "<div style=\"#{style}\" id=\"#{id}\"></div>" offset += width src.push '</div>' document.write src.join('\n') # すべてのフォルダの画像一覧を出力する outputFolders: -> for i in [0..(@images.length-1)] @outputFolder(i) # folder 名の '_' を ' ' に変えて読みやすくする humanizeFolder: (folder)-> folder.replace(/_/, ' ') # イベントハンドラの割り当て registerEvents: ()-> $('.photo .images div').bind 'click', (e)=> [folder_id, image_id] = e.target.id.split('-') @openImage(folder_id, image_id) $('.photo a#back').bind 'click', (e)-> false $('.photo a#back').bind 'mousedown', (e)=> @closeImage() $(window).bind 'popstate', (e)=> if (window.location.hash != "#browsing") and $("#image_frame").is(':visible') @closeImage() $(window).bind 'resize', (e)=> @resizeComponents() $('.photo a#next').bind 'click', (e)-> false $('.photo a#next').bind 'mousedown', (e)=> @nextImage() false $('.photo a#prev').bind 'click', (e)-> false $('.photo a#prev').bind 'mousedown', (e)=> @prevImage() false $('.photo #image_frame').bind 'mousedown touchstart', (e)=> @dragStart(e) $(document).bind 'mousemove touchmove', (e)=> @dragMove(e) $(document).bind 'mouseup touchend', (e)=> @dragEnd(e) $(document).bind 'touchcancel', (e)=> @dragCancel(e) openImage: (folder_id, image_id)-> @folder_id = parseInt folder_id @image_id = parseInt image_id @folder = @images[@folder_id][0] @image = @images[@folder_id][1][@image_id] @resizeComponents() $('.photo #image').attr('src', "#{@photo_root}/#{@folder}/small/#{@image[0]}") $('.photo #large').attr('href', "#{@photo_root}/#{@folder}/large/#{@image[0]}") $('.photo #full_screen').show() $('body').bind 'keydown', (e)=> return true unless $("#image_frame").is(':visible') switch e.keyCode when 27 # Esc @closeImage() false when 37 # ArrowLeft @prevImage() false when 39 # ArrowRight @nextImage() false when 8 # BackSpace @closeImage() false else true window.location.hash = "browsing"; prevImage: (clear = false)-> if @image_id > 0 @discardImage() if clear @openImage(@folder_id, @image_id - 1) else if @folder_id > 0 @discardImage() if clear @openImage(@folder_id - 1, @images[@folder_id - 1][1].length - 1) else @flashButton $(".photo #prev"), 'red' return false @flashButton $(".photo #prev"), '#333' true nextImage: (clear = false)-> if @image_id < @images[@folder_id][1].length - 1 @discardImage() if clear @openImage(@folder_id, @image_id + 1) else if @folder_id < @images.length - 1 @discardImage() if clear @openImage(@folder_id + 1, 0) else @flashButton $(".photo #next"), 'red' return false @flashButton $(".photo #next"), '#333' true closeImage: -> $('#full_screen').hide() if (window.location.hash == "#browsing") window.history.back() discardImage: -> $('.photo #image').attr('src', '') resizeComponents: ()-> $("#controls a").css('font-size', Math.round(window.innerHeight*7/100) + "px"); # ボタンを一定時間、指定の色で光らせます flashButton: (button, color)-> button.css('background', color) .delay(300) .queue -> $(@).css('background', '') .dequeue() dragStart: (e)-> return true if @drag @drag = { start_x: @pageX(e) } e.originalEvent.preventDefault() e.originalEvent.stopPropagation() false dragMove: (e)-> return true unless @drag image_frame = $("#image_frame") image_frame.css("left", ( @pageX(e) - @drag.start_x ) + 'px'); e.originalEvent.preventDefault() e.originalEvent.stopPropagation() if Math.abs(image_frame.position().left) > 5 @drag.not_to_close = true false dragEnd: (e)-> return true unless @drag image_frame = $("#image_frame") if Math.abs(image_frame.position().left) > window.innerWidth * 15 / 100 image = $('#image') image.css('opacity', 0) image.animate({opacity: 1}, 300) if image_frame.position().left > 0 result = @prevImage(true) else result = @nextImage(true) if result image_frame.css('left', '0px') else image_frame.animate({left: '0px'}, 200) else if @drag.not_to_close image_frame.animate({left: '0px'}, 200) else @closeImage() image_frame.css('left', '0px') e.originalEvent.preventDefault() e.originalEvent.stopPropagation() @drag = null false dragCancel: (e)-> return true unless @drag $("#image_frame").animate({left: '0px'}, 200) e.originalEvent.preventDefault() e.originalEvent.stopPropagation() @drag = null false pageX: (e)-> if e.originalEvent.changedTouches e.originalEvent.changedTouches[0].pageX else e.pageX
rmagick は遅いので、他のライブラリを使った方がいいかもしれませんね†
こちらで紹介されている speedpedal というのを試してみたいと思っています。