implicit-toc.js

  • /**
     * @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, '&lt;');
    			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;
    			}
    		};
    	}
    })();
    
  • Permalink
    このページへの個別リンクです。
    RAW
    書かれたコードへの直接のリンクです。
    Packed
    文字列が圧縮された書かれたコードへのリンクです。
    Userscript
    Greasemonkey 等で利用する場合の .user.js へのリンクです。
    Loader
    @require やソースコードが長い場合に多段ロードする Loader コミのコードへのリンクです。
    Metadata
    コード中にコメントで @xxx と書かれたメタデータの JSON です。

History

  1. 2012/11/04 13:11:00 - 2012-11-04