さて、
PHP で書かなければならないような所は概ね済んだように思います。
(実は、まだ細かいのがいくつかあったりするのですが・・・それはまた後で)
ということで、
今回は JavaScript でやる部分について書いていきたいとおもいます。
theme.js
JavaScript で書く部分は全て js/theme.js という名前のファイルにまとめることにします。
そして、wp_enqueue_script() を functions.php に書いて読み込ませます。
目次の自動出力
本文中に、見出しである <h2> ~ <h6> があれば自動的に目次を出力するようにします。
こちらを参考にしました。
ということで、目次を生成する JavaScript のコードは以下のようになりました。
function generateTOC() {
// 記事の目次を生成。参考→https://wemo.tech/67
var html, level, depth, headings, toc,
entry = document.querySelector('.entry-content');
if (entry) {
headings = entry.querySelectorAll('h2,h3,h4,h5,h6');
if (headings.length && headings[0].tagName === 'H2') {
level = 0; // 0~4 -> h2~h6
depth = [0,0,0,0,0]; // h2~h6
html = '<div class="toc-content"><header class="toc-header"><span class="toc-title">目次</span></header><ul class="toc-list">';
Array.prototype.forEach.call(headings,function(h) { // HTMLCollection -> array
var i,d,
l = parseInt(h.tagName[1],10) - 2;
if (l > level) {
// h2 -> h4 とか深くなる時に間を飛ばすと不正なタグ構造になるので注意!
for (i=level;i<l;i++) {
html += '<ul class="toc-list">';
}
} else
if (l < level) {
// h4 -> h2 とか浅くなる時に間を飛ばしても不正なタグ構造にはならないので大丈夫。
for (i=level;i>l;i--) {
html += '</ul>';
depth[i] = 0;
}
}
level = l;
depth[level]++;
d = depth.slice(0,level+1);
h.id = 'toc-' + d.join('-');
html += '<li class="toc-item"><a href="#' + h.id + '"><span class="toc-number">' + d.join('.') + '</span> ' + h.textContent + '</a>'; // liの閉じタグが省略可能なのを利用して、入れ子をうまくやる(^_^;)
});
if (level > 0) {
while (level--) {
html += '</ul>';
}
}
html += '</ul></div>';
toc = document.createElement('p'); // or 'div'
toc.id = 'toc';
toc.innerHTML = html;
headings[0].parentNode.insertBefore(toc,headings[0]);
// widgetがあれば自動で追加。
toc = document.getElementById('widget-toc'); // ← text widget に仕込んでおくこと!
if (toc) {
toc.innerHTML = html;
// スクロール位置に応じて目次項目を注視状態にする。次のDOM更新時にやるようにsetTimeout()でタイミングをずらす。
setTimeout(function() {
var items = Array.prototype.slice.call(document.querySelectorAll('#widget-toc a[href^="#toc-"]'));
window.addEventListener('scroll',function() {
var positions;
if (!visibleInViewport(toc)) {
return;
}
// 各見出しのページ内の位置を求める。比較時にうまく行かないことがあるので小数点以下は切り捨てるようにする。
positions = [];
Array.prototype.forEach.call(headings,function(h) {
positions.push((pageYOffset + h.getBoundingClientRect().top - 30)|0); // 少し上方へずらした位置にする。
});
positions.push((pageYOffset + entry.getBoundingClientRect().bottom)|0); // 記事本文の最終位置。
items.forEach(function(item,idx) {
var clist = item.classList,
cname = 'toc-focus', // 注視状態。
poff = pageYOffset|0;
if (positions[idx] <= poff && poff < positions[idx+1]) {
if (!clist.contains(cname)) {
clist.add(cname);
}
} else {
if (clist.contains(cname)) {
clist.remove(cname);
}
}
});
function visibleInViewport(el) { // 表示範囲内に対象要素(その一部分でも)が見えているかどうか。
var r1 = el.getBoundingClientRect(),
r2 = {
left: 0,
top: 0,
right: window.innerWidth,
bottom: window.innerHeight
};
return r1.left < r2.right && r1.right > r2.left && r1.top < r2.bottom && r1.bottom > r2.top; // detect collision
}
});
},0);
}
// スクロールアニメーションを仕込む。次のDOM更新時にやるようにsetTimeout()でタイミングをずらす。
setTimeout(function() {
Array.prototype.forEach.call(document.querySelectorAll('a[href^="#toc-"]'),function(link) {
link.addEventListener('click',onclick);
function onclick(e) {
var offsetY,baseY,lastTime;
e.preventDefault(); // 既定の動作を抑制←これ重要。
offsetY = (document.getElementById(this.hash.slice(1)).getBoundingClientRect().top - 30)|0; // 少し上方へずらして見やすくなるように考慮
baseY = pageYOffset|0;
lastTime = performance.now();
tick(lastTime);
function tick(time) {
var elapsed = (time - lastTime) * 0.001; // millisec to sec
if (elapsed < 0.3) { // 到達まで0.3秒。
scrollTo(pageXOffset,baseY + (offsetY * elapsed / 0.3));
requestAnimationFrame(tick);
} else {
scrollTo(pageXOffset,baseY + offsetY);
}
}
}
});
},0);
}
}
}
ちょっと長いので解説していきます。
記事の本文は entry-content クラス内にあるので、querySelector() を使って対象となる DOM を抽出します。
続いて、その DOM から querySelectorAll(‘h2,h3,h4,h5,h6’) とやって、見出しである <h2> ~ <h6> を全て抽出しておきます。
見出しが見つかれば目次を出力します。
目次は <ul> を使って構造化します。
見出しの番号が増える時は階層を成すようにするために、<ul> の入れ子構造とします。
抽出した全ての見出しの DOM について、繰り返し処理を行っていきます。
コードでは、見出しの番号を level、階層の深さを depth としてやっています。
その際に、合わせて番号も決めて行きます。
例えば、同じ階層なら 1 → 2、深くなるなら 1 → 1.1 → 1.1.1 といった具合です。
そして、見出しの DOM には id=”toc-番号” を、目次の項目である <li> には対象となる見出しへのリンクを href=”#toc-番号” といった感じに属性をそれぞれ付加します。
出来上がった HTML なテキストを最初の見出しの前に挿入すれば出来上がりです。
ちなみに、階層を成す場合は <ul> の入れ子になるわけですが、
そうなった場合に、目次の項目である <li> の閉じタグを入れる操作が難しいことになってしまったため(再帰とか使えばやれるかもなのだけど・・・やり方が思いつかなかったし面倒だったので)、閉じタグが省略可能なことを利用してその辺をうまくやっていたりします(^_^;)
なお、
見出しは <h2> から始まることを前提とします。
また、<h2> → <h4> のように、深くなる時に間を飛ばすと不正なタグ構造になるので注意してください。
そうなった場合でも一応動くようにはしてありますが・・・。
ウィジェットに自動出力
せっかくなのでウィジェットにも自動出力できるようにしました。
テキストなウィジェットを追加し、以下のように書けばウィジェットにも目次を出力できます。
<div id="widget-toc"></div>
スクロール位置に応じて目次項目を注視状態にする
後で追加で入れた機能があったのに書き忘れていました(^_^;)
以下に追記します。
ウィジェットな目次がある時は、スクロール位置に応じて項目を注視状態にするようにしました。
具体的には対象となった目次項目のリンクに class=”toc-focus” が付きます。
各見出しのページ内での位置を求めておいて、ページの現オフセット値がどの部分にあるかで判断しています。
Qiitaのページでやっているのを見て、良さげに思えたのでマネして自作してみました(^_^;)
以下のようにスクロール位置に応じて赤く表示してみました。
スクロールアニメーション
目次のリンクをクリックすれば対象となる見出しへ瞬時に飛ぶようになります。
機能としてはこれで問題ないのですが、ちょっとサッパリしすぎな感じなので、スクロールアニメーションさせてみることにします。
こちらを参考にやってみました。
querySelectorAll(‘a[href^=”#toc-“]’) とやって、抽出した全ての目次リンクに対して click イベントを仕掛けます。
イベントが発火したら対象となる見出しのページ内オフセットを求め、requestAnimationFrame() のタイミングで、scrollTo() を使って初期のページオフセットから目的のオフセットまで移動させることで、スクロールアニメーションさせます。
到達時間を固定にするやり方を採ったので、対象の見出しまでの距離が遠いほどスクロールは速くなります。
検索ボタンの動作
次に検索ボタンの動作を作ります。
この回で書いたように、
サイトヘッダーにおけるメニューは以下のようにしました。
「 Webサービス ブログパーツ 」
そして、検索ボックスについては必要に応じて見え隠れできるようにすることにしました。
そこで、 を押したら、そういう動作をするコードを書きます。
以下のようになりました。
function searchButtonAction() {
var el = document.querySelector('#site-navigation a[title="検索"]');
if (el) {
el.addEventListener('click',function(e) {
var searchBox;
e.preventDefault();
searchBox = document.querySelector('#site-search');
if (searchBox) {
// トグル動作。
if (searchBox.classList.toggle('site-search-show')) {
searchBox.querySelector('.search-field').focus(); // 入力欄を注視状態に。
}
this.classList.toggle('search-menu-active'); // 検索ボックスがアクティブかどうかを示す。
}
});
}
}
site-search-show クラスの有無に応じて、見え隠れするように CSS を書いておきます。
なお、querySelector() で正しく抽出できるようにあらかじめタグのidや属性を設定してあります。
投稿一覧の個々をリンクの様に動作させる
記事一覧における構造は以下にようになっています。
タイトルには投稿へのリンク、メタ情報には、カテゴリやタグへのリンクが含まれたりします。
投稿の本文へ遷移するにはタイトルをクリックしてもらう必要がありますが、本文以外では リンクの下線は表示しない方針としています。
このままではうまくない感じですので、<section> 全体をリンクの様な動作になるようにしたいと思います。
<a> でやるのが簡便なわけですが、<a> の中に <a> を作ることはできません!
リンク中のリンクというのは無理なわけです。
そこで、<section> に対して、投稿URLを含む data-url 属性を付加して JavaScript で処理することにします。
ということで、一覧の個々を出力する content-excerpt.php を以下のように修正します。
<?php
printf('<section class="post post-link clearfix" data-url="%s">', esc_url( get_permalink() ) );
the_title( sprintf( '<h2 class="entry-title"><a href="%s">',esc_url( get_permalink() ) ),'</a></h2>' );
get_template_part( 'parts/meta' );
get_template_part( 'parts/thumbnail' );
echo '<div class="content-excerpt">';
the_excerpt();
echo '</div>';
echo '</section>';
そして、リンクの様に動作させるための JavaScript のコードは以下のようになります。
function linkifyPostIndex() {
// 投稿一覧における .post-link data-url="xxx" のdomをaタグのように動作させる。
Array.prototype.forEach.call(document.querySelectorAll('.post-link[data-url]'), function(el) {
el.addEventListener('mousedown',onmousedown);
el.addEventListener('mouseup',onmouseup);
stopPropagationInner(el);
function onmousedown(e) {
// 中ボタンの自動スクロール機能を無効にする → https://superuser.com/questions/44418/how-to-disable-the-middle-button-scrolling-in-chrome
if (e.button === 1) {
e.preventDefault();
e.stopPropagation();
}
}
function onmouseup(e) {
var url;
e.preventDefault(); // 一応やっておく。
url = this.dataset.url; // 'data-url' attribute;
if (e.button === 0) { // left click
location.href = url;
} else
if (e.button === 1) { // mid click
open(url,'_blank','noopener=yes,noreferrer=no'); // 第3引数を一応やっておく。
}
}
function stopPropagationInner(element) {
// 内側リンクの中ボタン押下イベントは外側へ伝播させない! タブを2個開いてしまうので(^_^;)
Array.prototype.forEach.call(element.querySelectorAll('a'), function(el) {
el.addEventListener('mousedown',onmdlclick);
el.addEventListener('mouseup',onmdlclick);
function onmdlclick(e) {
if (e.button === 1) {
e.stopPropagation();
}
}
});
}
});
}
ちょっと込み入った感じになっているので解説します。
左ボタンによるクリックの場合は割とシンプルなコードになるのですが、中ボタンのクリックに対応させようとするとちょっとややこしいことになります。
通常、リンクをマウスの中ボタンでクリックすると、ブラウザはタブを新規追加してページを開いたりします。
なので左クリックの時は location.href = url; とやってページ遷移しますが、中クリックの時は open(url,’_blank’); とやる必要があります。
また、左クリックと異なり中クリックは追加でイベント伝播を対処する必要があります。
(左クリックの場合は対処しなくても何故かうまく行ってくれますね)
一つは、タイトルやメタ情報などの内側にあるリンクを中クリックした場合に、外側にある <section> が反応しないように、イベント伝播を止めるようにします。
止めないと外側も反応してタブが2個開いてしまいますので(^_^;)
もう一つは、たぶん Chrome系に限ったことになると思われますが、中クリックすると自動スクロール機能が有効になってしまってウザい感じになるので、こちらを参考にして無効にしておきます。
次回に続きます。