PDF.jsとReactでブラウザ上で完結する暗記帳的なものをつくった。
マーカーをひいて、シートをかぶせるとマスクされるおなじみのやつですね。
画像がブラウザだけで開けるというのは知っていたのですが、PDFがJavaScriptで扱えると聞いてつくってみた次第です。
ファイルのアップロードなし、バックエンドサーバーなしで動かせたので、だいたい目標は達成できた感じです。
PDF.jsその他、いろいろ躓いたポイントがあったのでメモとして残しておきます。
動いてるもの
ソース
PDF.js
まず動かしてみたときにはこちらのサイトを参照しました。 PDF.js で遊んでみた (ページの描画,テキスト・注釈の表示など) - きちぽよ〜
また、ここわからんよ……となったときにはこちらのサイトを読んで解決できました。 pdf.jsを使いブラウザで見られるPDFスライド表示ツールを作った | Web Scratch
PDFの取得にはファイルパスかTypedArray
をわたす
レンダリングにはPDFJS.getDocument
メソッドを使います。このメソッドが引数をみて動作を変えるので、考えることはあまりありません。
PDFJS.getDocument
はPromise
を返しますので、細やかなレンダリングの設定はそこで行います。
サーバーなどにファイルがある場合
Marker Workbookでは初期ページとしてsample.pdf
を表示するようにしています。この場合は
PDFJS.getDocument('/sample/sample.pdf').then((pdf)=> {
// あれこれ
})
とするとPromise
が返ってきますので、あれこれします。
input[type="file"]
経由でファイルをわたす場合
今回のMarker Workbookはサーバーへのファイルのアップロードはしない、という前提で作りましたから、ローカルファイルを表示する場合はブラウザがアクセスできるパスがありません。
そこで、input[type="file"]
からファイル情報を得て、それをデータに変換する手順が必要となります。
input[type="file"]
のonChange
イベントにリスナーを付与して、
onChange(e){
let reader = new FileReader();
reader.addEventListener('load', (e)=>{
//let typedArray = new Uint8Array(e.target.result);
PDFJS.getDocument(e.target.result).then((pdf)=> {
// あれこれ
})
});
reader.readAsArrayBuffer(e.file);
}
得られた情報からあれこれします。
(最初試した時にUint8Array
しないと動かなかった気がしたのですが、今ためすとそのままでも解釈できたのでよくわかりませんが残しています。)
レンダリングは非同期
PDFJS.getDocument
により生っぽいデータを得ることができました。
これはまだ生っぽい、webページのためのHTMLのような状態なので、表示できる形にしなければなりません。
PDF.jsはpdf
が持つ個別のpage
のrender
メソッドを呼び出すまでレンダリングしません。これのおかげで最初のPDFJS.getDocument
で長時間かかってしまうということがありません。
そのかわり、render
時には文字やその他のレイアウトのため、多少時間がかかります。動作をブロックしないために、ここでもPromise
が使われ、非同期的に処理が行われます。
render
では任意のCanvasRenderingContext2D
に転写し、表示するという形になります。
let pageNumber = 1;
let scale = 1;
PDFJS.getDocument(e.target.result).then((pdf)=> {
pdf.getPage(pageNumber).then((page)=> {
let viewport = page.getViewport(scale);
let {canvas, canvasContext} = this.setupCanvas(viewport);
page.render({canvasContext, viewport})
})
})
setupCanvas(viewport) {
let canvas = document.createElement('canvas');
let canvasContext = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
return {canvas, canvasContext};
}
pdf
から指定したページのpage
を得るpage
からサイズ情報を含むviewport
を得るviewport
をもとにCanvasElement
、CanvasRenderingContext2D
を用意するpage.render
にviewport
とCanvasRenderingContext2D
を渡すpage.render
がCanvasRenderingContext2D
に転写、Canvas
に表示される
Canvas
からdataURL
を得る
アプリケーションではCanvas
での表示を考えず、React
との兼ね合いもあって単純にImage
タグで表示しようとおもっていましたので、canvas.toDataURL()
でdataURL
を得なければなりません。
(CanvasContext
もとのCanvas
を利用する場合はこの非同期は気にしなくてよい)
しかしpage.render
は非同期なので、
page.render({canvasContext, viewport})
let dataURL = canvas.toDataURL();
とすると悲しくなることになります。
残念ながらpage.render
はPromise
を返すわけではないので、ダイレクトにreturn page.render({canvasContext, viewport})
とはできませんが、page.render
が返すRenderTask
からPromise
を得ることができます。
page.render({canvasContext, viewport}).promise.then((e)=> {
let dataURL = canvas.toDataURL();
// あれこれ
});
これでレンダリングが終わった状態のdataURL
を得ることができました。
React
複雑なオブジェクトを条件としたshouldComponentUpdate
をどうするか
shouldComponentUpdate
をていねいに設定していくことにより、無駄なrender
が減り、幸せになることができます。
しかし、ちょっと条件が増えてくるとちょっとした比較もめんどくさくなってくるので、state
にいるインスタンスにバージョンをもたせて、それをみてそれでよしとする乱暴な方法にしました。
以下はマーカーを表示するコンポーネントの部分です。
shouldComponentUpdate(props, _) {
return this.state.markerVersion !== props.workbook.currentPage.markerVersion
}
componentWillReceiveProps(props) {
this.setState({
markerVersion: props.workbook.currentPage.markerVersion
})
}
マーカーを追加したり、消したりするたびにmarkerVersion
はインクリメントされます。
shouldComponentUpdate
が通るとstate
のmarkerVersion
が更新されて、次回に備えるという塩梅です。
KeyBoardEvent
今回はキーボードショートカットに多少挑戦しました。
ところでキー押下には、アクティブになっているinput
エレメントに対して思わぬ動作を引き起こすものがあります。
スペースはアクティブになっているbutton
を押下してしまいますし、カーソルキーはselect
を次の項目に動かしてしまいます。
Chromeでは単純にe.preventDefault
を用いるだけでそのような動作を抑止出来ましたが、Firefoxではそうはいきませんでした。残念ですね。
let active = document.activeElement as HTMLElement;
active.blur();
let retrieve = ()=> {
active && active.focus()
$(window).unbind('mosueup', retrieve);
};
$(window).bind('mosueup', retrieve);
e.preventDefault();
ということで、上記のようにキーイベントに合わせてフォーカスをつけるはずすなどを用いてみました。
しかしFirefoxではbutton
に対する抑止は有効であるものの、select
では相変わらずキーを拾ってしまうので、onChange
でフォーカスを外すことになりました。
<select className="scale" value={this.props.scale} onChange={(e)=> {
e.target.blur()
this.dispatch('workbook:scale', +e.target.value)
}}>
うーん、残念。
以上です
以上です。