anti scroll

ブラウザと小説の新しい関係を模索する

Kefir.jsを使ってnehan.jsなビューアーをReactiveに作る

Kefir.jsを使ったのは、ドキュメントがわかりやすくて綺麗だったからです。

ビューアーの要件

  1. NEXTクリックでページを進める。
  2. PREVクリックでページを戻す。
  3. $(“#page-count”)に、現在の総ページ数を表示する(非同期で増える)。
  4. $(“#page-no”)に、現在のページ番号を表示する。
  5. ただしページ番号は、総ページ数の値を超えることはできない。

実装方針

  • 非同期で新しいページが出力されるたびに「現在の総ページ数」が出力されるストリームpage_count_streamを作る。
  • 現在のページ数とNEXT/PREVクリックのアクションを合成して、現在のページ位置を出力するストリームpage_index_streamをる。
  • page_count_streamを購読し、$(“#page-count”)を更新する。
  • page_index_streamを購読し、$(“#screen”)に該当ページを出力しつつ、$(“#page-no”)にページ番号を出力する。

page_count_streampage_index_streamを宣言的に(ステートを持たないように)表現できたら成功。

ページ数のストリームを作成

nehan.jsが作ったページ数を受け取るストリームをKefir.bus()で作成しておきます。

var page_count_stream = Kefir.bus();

ページ位置のストリームを作成

NEXT/PREVのクリックをそれぞれ+1-1を出力するストリームにし、それらを一つに束ねたものをclick_value_streamとして定義します。

var next_click_stream = $("#next").asKefirStream("click");
var prev_click_stream = $("#prev").asKefirStream("click");
var click_value_stream = Kefir.merge([
  next_click_stream.mapTo(1), 
  prev_click_stream.mapTo(-1)
]);

ここからは少し面倒です。

こういうnext/prevでカウントするサンプルって、大抵はストリームの値を合算したものを現在値みたいにするケースが殆どです。

// これだと使えない
var sum_stream = Kefir.scan(click_value_stream, function(acm, value){
  return acm + value;
});

しかしこれだと上手くいかないのです。

なぜならこちらが欲しいのは、下限と上限付きの値だからです。

言うまでもなく、下限は先頭ページ位置の0で、上限は「現在の」出力可能なページ数をマイナス1した値です。

ページ数が4(最大インデックス3)なのに、4とか5を出力されても困りますよね。

例えば2回だけ上限オーバーした数値は、PREVを2回押して戻さないと使えないってことになりますので。

だから単なる合算ではなく、現在のページ数を参照して合算するストリームが必要になります。

最初にミスったこと

これを実現するにあたって最初にミスったのはKefir.zipを使って、クリックのストリームとページ数のストリームを合成することでした。

しかしこれは上手く行きません。

なぜならクリックのストリームがclickイベントが発生する度に出力する無限ストリームであるのに対し、ページ数のストリームは最終ページを出力したらもう新しい値を出力しない有限サイズのストリームだからです。

有限のストリームと無限のストリームをzipすると、短い方に合わせて有限のストリームが出来上がります。

つまりKefir.zipを使って合成したストリームを購読しても、有限回しか操作できません。

こういう2つのストリームを一つの無限ストリームに束ねるにはどうしたらいいのでしょう。

解決策「Kefir.combine」

そこでドキュメントをざっと眺めて見つけたのがKefir.combineです。

この関数は、対象ストリームの「最新の値」を使ってストリームを合成できます。

つまり片方の長さが3しかなくても、もう片方が無限なら、有限な方の最後の値が無限に参照できます。

結果、こんな実装になりました。

var page_index_stream = Kefir.combine([
  click_value_stream, 
  page_count_stream
], function(value, count){
  return {value:value, count:count};
}).scan(function(acm, cur){
  return {
    // 0 ~ [cur.count - 1]
    value:Math.max(0, Math.min(cur.count - 1, acm.value + cur.value)), 
    count:cur.count
  };
}, {value:0, count:0}).map(function(obj){
  return obj.value;
});

それぞれのストリームを購読

さて、なんとかpage_count_streampage_index_streamを定義することができました。

後は、page_count_streampage_index_streamをそれぞれ購読し、必要なUIを更新する処理を定義するだけです。

ページ出力のタイミングでpage_count_streamに最新のページ数がemitされるので、それに合わせて、紐付いたUIが連動して変化するようになっています。

var paged_element = Nehan.createPagedElement();

// ページ数を購読
page_count_stream.onValue(function(count){
  $("#page-count").html(count);
});

// UI操作とページ数によって決まる現在のページ位置を購読
page_index_stream.onValue(function(index){
  $("#page-no").html(index + 1);
  paged_element.setPage(index); // 現在位置を更新
});

// ページ計算を開始
paged_element.setStyle("body", {
  "flow":"tb-rl", // or "lr-tb"
  "font-size":16,
  "width":500,
  "height":300
}).setContent($("#screen").html(), {
  onProgress:function(tree){
    // ページ出力のタイミングで現在のページ数を出力
    page_count_stream.emit(tree.pageNo + 1);
  }
});

// 画面上に組版結果を表示する
$("#screen").after(paged_element.getElement());

感想

色々なUI要素が絡むような複雑なアプリなら、注目している値を透過的に取得できるので、安全にUIを更新できそうな気がしますが、今回のような単純なケースでは少し面倒かもしれません。