/**
* @title implicit-toc.js
* @description 見出しっぽいものをマウスオーバー or ダブルクリックすると目次を表示
* @include http://*
* @license MIT License
*
* @author s_hiiragi
* @updated 2012/11/03 14:40:51
*/
/* Implicit Table of Contents
*
* 概要
*
* マウスオーバー or ダブルクリックしたテキストが見出しっぽければ、
* 同様の見出しを探して目次を表示します。
*/
/* 技術的な説明
*
* 見出しの判定基準
* - 要素が見出し要素(header, h{n}, li)だった (liはマウスオーバー時のみ)
* - 要素が見出し要素(header, h{n}, li)に含まれている (liはマウスオーバー時のみ)
*
* 同様の見出しの検索法
* 1. 見出し要素から(#hoge > fuga > piyo > ...)のようなCSSセレクタを生成
* 2. セレクタにマッチする見出し要素を取得
*/
/* TODO
*
* - 見出し要素にidが設定されている or name付きa要素が含まれている場合、
* そのid or nameをCaptionViewのアンカーに設定する (scrollToの代わりに)。
*/
(function() {
// メインコード
var debug //= true;
var Caption, CaptionListView, CaptionView;
defineClasses();
log('Caption', Caption);
log('CaptionListView', CaptionListView);
log('CaptionView', CaptionView);
document.addEventListener('dblclick', function(event) {
log('dblclick', event);
log('selection', window.getSelection());
var selCaption = getSelectedCaption();
log('selCaption', selCaption);
if (!selCaption) return;
showCaptionList(selCaption);
}, true);
document.addEventListener('mouseover', function(event) {
log('mouseover', event);
log(event.target.tagName);
console.warn('mouseover ',
(event.fromElement? event.fromElement.tagName: null) + ' => ' + event.toElement.tagName);
// [ [from] to ] の場合
// ※ DOMツリー上で to 要素に from 要素が含まれていることを表している
// event.targetが見出し要素だとすると、
// 見出し要素内の要素から見出し要素への移動は無視する
if (event.target.contains(event.relatedTarget)) {
return;
}
// 以降は [ from [to] ] の場合
// [from][to]の場合、[[from] div], [div [to]]のように分かれてイベントが来るので
// [[from] div]は見出し要素と無関係なので無視されて、
// 結局、[div [to]] == [from [to]]を処理することとなる。
// TODO リストが二重に表示される問題 - 2012/11/04
//
// 問題のケース
// DIV => H2 ... リスト表示
// H2 => SPAN(祖先要素のH2に置換) ... リスト表示
//
// 同じセレクタのリストは表示しないようにする。
// globalプロパティで表示中のセレクタを管理し、同じセレクタなら排除
var selElement = event.target;
var reHeader = /^(?:header|h\d)$/i;
if (!reHeader.test(selElement.tagName)) {
// 先祖要素にheader, h{n}がないか調べる
var header = getAncestorElement(selElement, reHeader);
if (!header) return;
selElement = header;
console.warn('replace selElement', header);
}
var selCaption = new Caption(selElement)
.selected(true);
log('selCaption', selCaption);
if (!selCaption) return;
showCaptionList(selCaption);
}, true);
return;
// ロジック
function log(/* arg1, arg2, ..., argN */) {
if (debug) {
console.debug.apply(console, arguments);
}
}
function assert(message, value) {
if (debug && !value) {
console.error(message);
}
}
function showCaptionList(selCaption)
{
var captionList = getSiblingCaptions(selCaption);
log('captionList', captionList);
// 見出し数が1(= 選択した項目のみ)の場合はリストを表示しない
if (captionList.length == 1) return;
console.warn(':::: mouseover :::: ');
var captionListView = new CaptionListView(captionList);
log('captionListView', captionListView);
captionListView.show();
// console.warn('captionList.entered ' + !!captionListView.data.entered);
selCaption.element().style.bordr = '1px solid red';
selCaption.
onMouseLeave(function() {
// console.warn('caption.leave');
setTimeout(function() {
// console.warn('caption.leave.timeout ' + !captionListView.data.entered);
if (!captionListView.data.entered) {
captionListView.dispose();
}
}, 200);
});
captionListView.
onMouseEnter(function() {
this.data.entered = true;
// console.warn('captionList.enter ');
}).
onMouseLeave(function() {
// console.warn('captionList.leave ');
if (this.data.entered) {
this.dispose();
}
});
/*
caption -> list -> 外 : ok
caption -> 外
*/
}
function getSelectedCaption() {
var selElement = window.getSelection().anchorNode;
if (selElement.nodeType != Node.ELEMENT_NODE)
selElement = selElement.parentElement;
log('selElement', selElement);
if (/^(?:input|textarea)$/i.test(selElement.tagName))
return null;
// 先祖要素にheader, h{n}, liがないか調べる
var reHeader = /^(?:header|h\d|li)$/i;
if (!reHeader.test(selElement.tagName)) {
var header = getAncestorElement(selElement, reHeader);
if (header) {
selElement = header;
console.warn('replace selElement', header);
}
}
return new Caption(selElement)
.selected(true);
}
function getSiblingCaptions(targetCaption) {
var targetElt = targetCaption.element();
var selector = getSiblingsCssSelector(targetElt);
log('selector', selector);
console.warn('selector', selector);
var siblings =
Array.prototype.slice.call(document.querySelectorAll(selector)).
map(function(e) {
if (e == targetElt) {
return targetCaption;
}
return new Caption(e);
});
return siblings;
}
/**
* 対象要素と同じ階層の要素を抽出するためのCSSセレクタを取得
*
* 対象要素から遡ってタグ名を取得し、'>'で連結する
* 途中でidを持った要素が出てきたら終了
* ただし対象要素のidは除く (同じ階層の要素を抽出できなくなるため)
*
* @param element
* 対象要素
* @return string
* CSSセレクタ
*/
function getSiblingsCssSelector(element) {
var selectorParts = [ element.tagName ];
for (var e = element.parentElement; e != document; e = e.parentElement) {
if (e.id) {
selectorParts.unshift('#' + e.id);
break;
}
selectorParts.unshift(e.tagName);
}
return selectorParts.join(' > ');
}
/**
* 条件に一致する先祖要素を取得
* @param element
* 探索を開始する要素
* @param ancestorTester String | RegExp | Function
* String: タグ名(大文字/小文字は区別しない)
* RegExp: タグ名とマッチングする正規表現
* Function: テスト関数
* function(element: HTMLElement): Boolean
* @return HTMLElement or null
* 先祖要素
*/
function getAncestorElement(element, ancestorTester) {
if (typeof ancestorTester == 'string')
{
var tagName = ancestorTester.toLowerCase();
ancestorTester = function (e) {
return (e.tagName.toLowerCase() == tagName);
};
}
else if (ancestorTester instanceof RegExp) {
var re = ancestorTester;
ancestorTester = function (e) {
return re.test(e.tagName);
};
}
var e = element;
while (e != null && !ancestorTester(e)) {
e = e.parentElement;
}
return e;
}
// クラス
/**
* 各クラスを定義
*/
function defineClasses() {
/**
* 見出しオブジェクト
*/
Caption = function(element) {
this._element = element;
this._text = element.textContent.
match(/^.*/)[0].
replace(/</g, '<');
if (this._text.length > 20) {
this._text = this._text.substring(0, 20) + '…';
}
this._selected = false;
this._listeners = [];
this._data = {};
};
Caption.prototype = {
element: function() {
return this._element;
},
text: function() {
return this._text;
},
selected: function(value) {
if (arguments.length == 0)
return !!this._selected;
this._selected = !!value;
return this;
},
rect: function() {
if (this._rect) {
return this._rect;
}
var rect = this._element.getBoundingClientRect();
this._rect = {
left : window.scrollX + rect.left,
right : window.scrollX + rect.right,
top : window.scrollY + rect.top,
bottom : window.scrollY + rect.bottom
};
return this._rect;
},
dispose: function() {
var that = this;
this._listeners.forEach(function(e) {
that._element.removeEventListener(e.type, e.listener, e.useCapture);
});
},
onMouseLeave: function(callback) {
var e = this._element;
var that = this;
e.addEventListener('mouseout', listener, true);
this._listeners.push({type: 'mouseout', listener: listener, useCapture: true});
function listener(event) {
if (e.contains(event.relatedTarget))
return;
return callback.apply(that);
}
return this;
}
};
/**
* 見出しリストビュー
*/
CaptionListView = function(captionList) {
this._captionList = captionList;
var selCaption = null;
captionList.
forEach(function(e) {
if (e.selected()) {
selCaption = e;
}
});
this._selectedCaption = selCaption;
assert('selCaption != null', selCaption != null);
var ul = document.createElement('ul');
var x = selCaption.rect().left,
y = selCaption.rect().bottom// + 5;
var props = {
display : 'none',
'list-style-type' : 'none',
position : 'absolute',
left : x + 'px',
top : y + 'px',
margin : '0px',
padding : '4px',
border : '1px solid #888',
'background-color' : '#EEE',
'max-height': '300px',
'overflow-y': 'scroll'
};
for (var i in props) {
ul.style.setProperty(i, props[i]);
}
document.body.appendChild(ul);
this._element = ul;
var that = this;
captionList.
forEach(function(caption) {
var cv = new CaptionView(caption);
ul.appendChild(cv.element());
cv.element().addEventListener('click', function() {
that.dispose();
}, true);
});
this._listeners = [];
this.data = {};
};
CaptionListView.prototype = {
element: function() {
return this._element;
},
show: function() {
this._element.style.display = 'block';
document.addEventListener('click', this, true);
return this;
},
hide: function() {
this._element.style.display = 'none';
this._removeEventListeners();
return this;
},
handleEvent: function(event) {
switch (event.type) {
case 'click': // TODO この処理は不要かも
log(event);
var e = this._selectedCaption.element();
if (e.contains(event.target)) return;
this.dispose();
break;
}
},
dispose: function() {
console.warn('dispose');
this._element.parentNode.removeChild(this._element);
this._removeEventListeners();
this._selectedCaption.dispose();
},
_removeEventListeners: function() {
document.removeEventListener('click', this, true);
var that = this;
this._listeners.forEach(function(e) {
that._element.removeEventListener(e.type, e.listener, e.useCapture);
});
},
onMouseEnter: function(callback) {
var e = this._element;
var that = this;
e.addEventListener('mouseover', listener, true);
this._listeners.push({type: 'mouseout', listener: listener, useCapture: true});
function listener(event) {
if (e.contains(event.relatedTarget))
return;
return callback.apply(that);
}
return this;
},
onMouseLeave: function(callback) {
var e = this._element;
var that = this;
e.addEventListener('mouseout', listener, true);
this._listeners.push({type: 'mouseout', listener: listener, useCapture: true});
function listener(event) {
if (e.contains(event.relatedTarget))
return;
return callback.apply(that);
}
return this;
}
};
/**
* 見出しビュー
*
* 見出しリストビューに含まれる
*/
CaptionView = function(caption) {
this._caption = caption;
var li = document.createElement('li');
if (caption.selected()) {
// 選択中の見出し
// 強調表示する
li.style.fontWeight = 'bold';
li.textContent = caption.text();
} else {
// その他の見出し
// クリックした時、見出しの位置にスクロールする
var a = document.createElement('a');
a.style.cursor = 'pointer';
a.onclick = function() {
window.scrollTo(caption.rect().left - 10, caption.rect().top - 10);
};
a.textContent = caption.text();
li.appendChild(a);
}
this._element = li;
};
CaptionView.prototype = {
element: function() {
return this._element;
}
};
}
})();