RubyはJavascriptと一緒にうまく遊べる

ブラウザの中でRubyが動くなんて本当にすごい。でも真実を語ろう。僕はJavascriptも好きだ。なんといってもJavascriptには死ぬほどライブラリがあるから。

"clicking on an image"をちゃんとしたい。いまは単純に新しいページをオープンするだけで,検索結果はなくなってしまうので「戻る」ボタンは意味が無い。イメージをクリックしたときにクールな視覚効果が使えたり,実際のFlickrのイメージへのリンクを持てたら素晴らしい。そんなライブラリがあればの話だけど…

http://www.huddletogether.com/projects/lightbox2/

前にも言ったとおり,Lightboxは素晴らしい。イメージをズームアップするにはLightboxを使う。まずindex.htmlに戻り,Lightboxを読み込む。

<script type="text/javascript" src="lightbox/js/prototype.js"></script> 
<script type="text/javascript" src="lightbox/js/scriptaculous.js?load=effects"></script> 
<script type="text/javascript" src="lightbox/js/lightbox.js"></script> 
<link rel="stylesheet" href="lightbox/css/lightbox.css" type="text/css" media="screen" />

Lightboxは,アンカータグの特殊なプロパティを使って,イメージがコレクションの一部かどうか,イメージのタイトルは何かなどの情報を格納している。Render#photoメソッドに戻って,:titleと:relの値を:aタグに追加しよう。

tag(:a)の呼び出しはこんな風になる。

tag(:a, {
  :href  => "#{img}",  
  :title => "&lt;a href=&quot;http://www.flickr.com/photos/#{p['owner']}/#{p['id']}&quot; target=&quot;_blank&quot;&gt;#{p['title']}&lt;/a&gt;", 
  :rel   => "lightbox[#{@tags}]" 
}) do 
  tag(:img, :src => "#{thumb}") 
end

最後に,レスポンスを受信したときにLightboxを初期化する。app.rbのshowメソッドreder呼び出しの後ろに以下のコードを追加する。

if document.overlay && document.lightbox 
  document.overlay.parent.remove_child document.overlay 
  document.lightbox.parent.remove_child document.lightbox 
end
HtmlPage.window.eval("initLightbox()") 

最後の行は最高に美しいとは言えないが,イメージは全て美しい。

Flickrの出力をページ化する

Flickrはリクエストの1ページだけを送ってくる。Flickrへのリクエストのoptionsを作ったときには,ページあたり30個のイメージを意味する:per_page=>30というエントリがあった。searchボタンをクリックしたときにcreate(document.keyword.value, 1)を呼び出すとき,2番目の引数はFlickrにクエストの1ページ目を要求している。だから任意のページを得るためには,createメソッドにその値を与えれば,該当のページを渡してくる。再利用するって言ったでしょ! generate_pagesを実装するには,そのページへのリンクをレンダリングすればよく,hook_page_eventsを実装するには,各ページリンクに指定のページでcreateを呼び出すようにフックする必要がある。簡単だね。

def generate_pages 
  render = "" 
  if @flickr['photos']['total'].to_i > 0 
    num_pages = @flickr['photos']['pages'].to_i > 10 ? 10 : @flickr['photos']['pages'].to_i 
    num_pages.times { |i| render += page(i + 1) } if num_pages > 1 
  end 
  render 
end 

def page(i) 
  tag(:a, :href => 'javascript:void(0)', :id => "#{i}") { "#{i}" } 
end 

def hook_page_events(div) 
  $app.document.get_element_by_id(div.to_s.to_clr_string).children.each do |child| 
    if child.id.to_s.to_i == @current_page 
      child.css_class = "active"  
    else 
      child.onclick { |s, args| $app.create(@tags, child.id.to_s.to_i) } 
    end 
  end 
end

あとは保存して,リロードして,検索して,歌って,踊って… きれいなページングアクションが見られるはずだ。

この気味悪いものを美しく

このFlickrのデータをどうすればいいのか ... うーん ... どう? このゴミの中のどこかに美しい写真があるのだから,絞り出すことにしよう。言い換えればJSONをパースしよう。app.rbの先頭に,以下の行を追加する。

require 'json'

これは最初にコピーしたjson.rbをロードする。これは単なるSystem::Json::JsonValueに対するモンキーパッチで,データへのアクセスを簡単にしてくれる。興味があるのなら,json.rbの該当部分はこれ。

require 'System.Json.dll' 
include System 

module System::Json 

  class JsonValue 
    def [](index) 
      item = self.get_Item(index.to_clr_string) 
      type = item.get_JsonType 
      return item.to_string.to_s.to_f if type == JsonType.Number 
      return item.to_string.to_s.split("\"").last if type == JsonType.String 
      return System::Boolean.parse(item) if type == JsonType.Boolean 
      item 
    end 
  
    def inspect 
      to_string.to_s 
    end 
  end 

end

これはJsonValueに[]メソッドを追加して,foo = {'bar' : 'baz'}であるようなJSONの値にfoo['bar']とアクセスできるようにする。便利。それはともかく我々のコードに戻ろう。app.rbで,showメソッドを定義し直して,JSONをパースしてなんか役に立つことをするようにしよう。

def show 
  @flickr = System::Json::JsonValue.parse(@response) 
  render 
end 

def render 
  @render = Render.new(@flickr, @options[:tags], @options[:page]) 
  document.search_images[:innerHTML] = @render.generate_photos 
  document.search_links[:innerHTML] = @render.generate_pages 
  @render.hook_page_events('search_links') 
  document.images_loading.style[:display] = "none" 
  document.search_results.style[:display] = "block" 
end 

showはFlickrのレスポンスに対してSystem::Json::JsonValue.parseを呼ぶだけになり,次にレンダリングを新しいクラスRender(あとで定義する)に委譲して,ロード中インジケータを止めるrenderという新しいメソッドを書いた。render.rbというファイルを作り,app.rbの中でrequireしよう。

require 'render'

render.rbを開き,写真のレンダリングにとりかかろう。

class Render 
  def initialize(flickr, tags, current_page) 
    @flickr = flickr 
    @tags = tags 
    @current_page = current_page 
  end 

  def generate_photos 
    if @flickr['stat'] == "ok" && @flickr['photos']['total'].to_i > 0 
      tag(:div, :class => 'images') do 
        @flickr['photos']['photo'].collect do |p|  
          photo(p) 
        end.join 
      end 
    else 
      "No images found!" 
    end 
  end 

  def photo(p) 
    source = "http://farm#{p['farm'].to_i}.static.flickr.com/#{p['server']}/#{p['id']}_#{p['secret']}" 
    thumb = "#{source}_s.jpg" 
    img = "#{source}.jpg" 
    tag(:div, :class => 'image') do 
      tag(:a, :href  => "#{img}") do 
        tag(:img, :src => "#{thumb}") 
      end 
    end 
  end 

  def generate_pages 
    "" 
  end 

  def hook_page_events(div) 
  end 

private 

  def tag(name, options, &block) 
    output = "" 
    output << "<#{name}" 
    keyvalue = options.collect do |key, value|  
      "#{key}=\"#{value}\"" 
    end 
    output << " #{keyvalue.join(" ")}" if keyvalue.size > 0 
    if block  
      output << ">" 
      output << yield  
      output << "</#{name}>" 
    else 
      output << " />" 
    end 
    output 
  end 
end

ちょっとばかりコードがあるが,tagの呼び出し以外は素直なものだ。プライベートメソッドのtagは,nameとoptionsに基づいてHTMLを生成すること以外の大半の仕事をする。HTMLをよりrubyらしく出力する(The private tag method does most of the work here, but generating HTML based on a name and options; it makes writing HTML more ruby-esk.)。またgenerate_pagesとhook_page_eventsが実装されていないことに気づいたことだろう。これらは今のところ実装できない。ブラウザをリロードして検索を行えば,イメージを取得できる。

RubyはFlickrが大好き

ボタンクリックをRubyにフックする方法は分かったので,Flickrと話して検索結果のデータを取得するようにしよう。まず最初に,Flickrとの通信方法を知る必要がある。initializeを以下のように変える。

def initialize 
  @url = "http://api.flickr.com/services/rest" 
  @options = { 
    :method => "flickr.photos.search", 
    :format => "json", 
    :nojsoncallback => "1", 
    :api_key => "6dba7971b2abf352b9dcd48a2e5a5921", 
    :sort => "relevance", 
    :per_page => "30" 
  } 
  document.submit_search.onclick do |s, e| 
    create(document.keyword.value, 1) 
  end 
end 

我々は分別のある大人なので,FlickrとはRESTで通信する。@optionsハッシュは,RESTの呼び出しに必要な様々なパーツを集めたものだ。我々は後でこれをURLにする関数を作る。重要なのは,我々がflickr.photos.searchを呼び出し,レスポンスとしてJSONを要求するということだ。onclickイベントは,神秘的な名前のcreateメソッドを呼び出す。これはまだ存在していないので,書くことにする。

def create(keyword, page) 
  @options[:tags] = keyword 
  @options[:page] = page 
  request 
end 

def request 
  make_url 
  request = Net::WebClient.new 
  request.download_string_completed do |sender, args| 
    @response = args.result 
    show 
  end 
  document.images_loading.style[:display] = "inline" 
  request.download_string_async Uri.new(@url) 
end 

createは再利用する必要がありそうだし,その引数はURIの一部としても必要なので,initializeの中で作った@optionsに追加しておく。そしてrequestメソッドを呼び出す。ここで「会話」が始まる。

最初に,@optionsからURLを作る(メソッドはすぐ作る)。そしてSystem::Windows::Net::WebClient(Silverlightの一部)のインスタンスに対して,Flickrに実際にリクエストを出すように通知している。images_loading.style[:display] = "inline" で,ロード中のインジケータが回り始める。レスポンスが帰ってきたら,showを呼び出す。これはなんか表示すべきだろうね。とりあえずレスポンスを出力して,ロード中インジケータを止める。

def show 
  puts @response 
  document.images_loading.style[:display] = "none" 
end

おっと。make_urlを作るちょうどいい頃合いだ。

def make_url 
  first, separator = true, '?' 
  @options.each do |key, value| 
    separator = "&" unless first 
    @url += "#{separator}#{key}=#{value}" 
    first = false 
  end 
end 

特別なことは何も無くて,"?"と"&"と"="を正しい場所におくだけだ。まだこれだけじゃ動作しない。app.rbを保存して,検索文字を入力して,searchをクリックすると,わけの分からないものが表示される。

IronRubyはDOMが好き

すごい。これでUIができたが,何もしない。Rubyの登場だ。ruby/app.rbを開いて,ハッキングを始めよう。
このアプリのポイントは,キーワードを入力してサーチボタンを押すと,そのキーワードに関連した画像をFlickrからダウンロードするところだ。なので,最初のステップとしてRubyにサーチボタンの押下を制御させることを提案したい。いいかな? よし。Appクラス全体を,以下のコードで置き換える。

def initialize 
  document.submit_search.onclick do |s, e| 
    puts "Search button pressed!"
  end
end

これはサーチボタンを押すたびにページの一番下に「Search button pressed!」と表示する。おっと,コードが言う通りだ。

すごいコードを書く! UIとか

前述したように,このアプリはHTML UIをIronRubyが駆動する。そう,Silverlightを美麗なグラフィック抜きで使うことも可能なんだ。これを実現するために,Silverlightの描画画面を非表示にする必要がある。index.htmlを開いて,28行目のwidthとhightを"100%"から"1"にする。

<object data="data:application/x-silverlight,"
type="application/x-silverlight-2-b2"
width="1" height="1">  

width/hightを"1"にすると,コントロールを非表示にできるが,実際には存在しているのでロードはされる。賢いハックだろ?

我々のアプリにUIを与えよう。以下をindex.htmlの最後にある""タグの後ろに入力する。

 <div class="search">   
   <form id="search" action="javascript:void(0)">   
     <input type="text" id="keyword" />   
     <input type="submit" id="submit_search" value="search" />   
     <img src="images/loading.gif" id="images_loading" />   
   </form>   
   <div id="search_results">   
     <div id="search_images"></div>   
     <div id="search_links"></div>   
   </div>   
 </div>   
 <div class="clear"></div>   

これは基本的なサーチ欄,サブミットボタン,見つかったイメージを表示する場所だ。スタイルをつけよう。stylesheets/screen.cssをオープンする。一番最初に「#silverlightControlHost」スタイルは,1×1のコントロールが100%の大きさになっては困るので不要だ。削除してくれ。

#silverlightControlHost {
  height: 100%;
}

いま作った素のUIにスタイルをつけよう。

body { 
  font-family: "Trebuchet MS" Verdana sans-serif; 
  border: 0px; padding: 0px; margin: 0px; 
} 

div.clear { 
  clear: both; 
} 

/* main search box */ 
.search { 
  padding: 20px; 
  margin: 20px; 
  border: 10px solid gray; 
  background-color: #ccc; 
} 

form#search #images_loading { 
  width: 18px;  
  height: 15px; 
  display: none; 
} 


/* search results */ 
#search_results { 
  display: none; 
} 

/* search images */ 
#search_images { 
  padding-top: 10px; 
} 
#search_images .image, .image a, .image a img { 
  float: left;  
  padding: 0px;  
  margin: 0px;  
  border: 0px; 
} 
#search_images .image a:link,  
#search_images .image a:visited { 
  background-color: white; 
  padding: 5px; 
  margin: 5px; 
  background-color: white; 
  border: 1px solid gray; 
} 
#search_images .image a:hover { 
  background-color: #ff9966; 
} 

/* search links */ 
#search_links { 
  clear: both; 
  padding-top: 10px; 
} 
#search_links a { 
  border: 1px solid #003344; 
  margin: 2px; 
  padding: 0px 5px; 
  color: #003344; 
  background-color: white; 
  text-decoration: none; 
} 
#search_links a:hover, 
#search_links a.active { 
  color: white; 
  background-color: #003344; 
  border: 1px solid white; 
} 
#search_links a.active { 
  cursor: default; 
}

ブラウザ画面をリロードすれば,この画面が表示される。

外部ライブラリの追加

このアプリはいくつかの外部ライブラリに依存しているので,コーディングを始める前にプロジェクトに入れよう。私は大変いい人間なので,ダウンロードしてもらったphotoviewer-start.zipに,依存しているものを全て入れておいた。zipの中身を取り出して,プロジェクトディレクトリにコピーする。rubysilverlight.rbを置き換えるかと警告が出るかもしれないが,問題ないので置き換える。やった? 素晴らしい。さて何を追加したのだろうか?

images/loading.gif
読み込み中のGIFイメージ。
ruby/System.Json.dll
JSONパーサ。Silverlight SDKに同梱のもの。
ruby/json.rb
System.Jsonへのモンキーパッチ。よりRubyっぽくするためのもの。
ruby/silverlight.rb
このアプリはsilverlight.rbのより新しいバージョンに依存しているので,生成したファイルをこれで置き換える。
lightbox/
イメージを美しく表示するjavascriptのライブラリ。大好き。