|
// ==UserScript== |
|
// @id moug@yu-tang |
|
// @name moug |
|
// @namespace https://gist.github.com/2068750 |
|
// @author yu-tang https://github.com/yu-tang |
|
// @version 0.5.3 |
|
// @description improve in user experiment at moug. |
|
// @include http://www.moug.net/faq/view* |
|
// @match http://www.moug.net/faq/view* |
|
// @resource CSS https://s3.amazonaws.com/alexgorbatchev.com/pub/sh/3.0.83/styles/shCoreDefault.css |
|
// @require https://s3.amazonaws.com/alexgorbatchev.com/pub/sh/3.0.83/scripts/shCore.js |
|
// @require https://s3.amazonaws.com/alexgorbatchev.com/pub/sh/3.0.83/scripts/shBrushVb.js |
|
// @require https://raw.github.com/odyniec/MonkeyConfig/master/monkeyconfig.js |
|
// ==/UserScript== |
|
|
|
/* |
|
SyntaxHighlighter の使用について |
|
================================ |
|
本来、SyntaxHighlighter の(作者さんによる)ホスティング先 URL は |
|
下記が正しいです。 |
|
http://alexgorbatchev.com/pub/sh/current/ |
|
ただ、この URL をそのまま @require に使用すると、依存ファイルを DL できず、 |
|
ユーザースクリプトのインストールに失敗します。詳しくは追っていませんが、 |
|
おそらく Amazon S3 にリダイレクトされる関係ではないかと想像しています |
|
(Scriptish と Greasemonkey で同様)。 |
|
上記の理由により、本ユーザースクリプトのメタデータでは、リダイレクト先の |
|
URL の方を書いています。これならインストールに失敗しません。 |
|
当然、将来ホスト先や URL 構成が変わればインストールに失敗する事態が |
|
予想されますので、ご注意ください。 |
|
また、作者さんはこのホスティングにより毎月 40$ ほどの出費になっている |
|
そうです。余裕のある方は寄付をご検討ください。 |
|
*/ |
|
|
|
{ // スクリプト開始 |
|
/* |
|
参考: |
|
JavaScript のブロックスコープと名前空間 « Mozilla Developer Street (modest) |
|
https://dev.mozilla.jp/2010/05/js-blockscope-and-namespace/ |
|
*/ |
|
|
|
//################################################### |
|
// config (for user) |
|
//################################################### |
|
let cfg = new MonkeyConfig({ |
|
title: 'moug+ ユーザー設定', |
|
menuCommand: true, |
|
params: { |
|
syntax_highlight: { |
|
'type': 'checkbox', |
|
'default': true, |
|
'label': 'シンタックス ハイライト' |
|
}, |
|
twitter_avatar: { |
|
'type': 'checkbox', |
|
'default': true, |
|
'label': 'twitter アバター' |
|
}, |
|
post_id: { |
|
'type': 'checkbox', |
|
'default': true, |
|
'label': 'レス番号' |
|
}, |
|
shortcut: { |
|
'type': 'checkbox', |
|
'default': true, |
|
'label': 'キーボード ショートカット' |
|
} |
|
} |
|
}); |
|
|
|
//################################################### |
|
// Get route |
|
//################################################### |
|
let route = window.location.pathname.slice(5, -4); // "/faq/hoge.php" -> "hoge" |
|
|
|
//################################################### |
|
// GM_xpath function |
|
//################################################### |
|
if (GM_xpath === undefined) { // for Greasemonkey (not Scriptish) |
|
var GM_xpath = function(arg) { |
|
var nl = document.evaluate( |
|
arg.path, |
|
(arg.node || document), |
|
null, |
|
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, |
|
null |
|
); |
|
if (arg.all) { |
|
nl.length = nl.snapshotLength; |
|
for (var i = 0, l = nl.snapshotLength; i < l; i++) { |
|
nl[i] = nl.snapshotItem(i); |
|
} |
|
return nl; |
|
} else { |
|
return nl.snapshotItem(0); |
|
} |
|
}; |
|
} |
|
|
|
//################################################### |
|
// Syntax Highlight |
|
//################################################### |
|
let syntaxHighlight = function() { |
|
// Gets code blocks. |
|
var pre = document.getElementsByTagName('pre'); |
|
if (pre.length == 0) return; // コード ブロックが見当たらなければ、終了 |
|
|
|
// Add class to code blocks. |
|
Array.forEach(pre, function(p){p.className += ' brush: vb;';}); |
|
|
|
// Add styles. |
|
GM_addStyle(GM_getResourceText('CSS')); |
|
/* |
|
後から読み込まれるスタイルに背景白の!important指定があるため、 |
|
それよりさらに優先度を上げる目的で、子孫セレクタを重ねます。 |
|
もっとスマートなやり方があるかも…。 |
|
*/ |
|
GM_addStyle('\ |
|
div[class="syntaxhighlighter nogutter vb"]\ |
|
>table>tbody>tr>td.code>div.container>div.line\ |
|
{background-color: whiteSmoke !important;}' |
|
); |
|
|
|
// Default settings |
|
SyntaxHighlighter.defaults['gutter'] = SyntaxHighlighter.defaults['toolbar'] = false; |
|
|
|
// Highlight |
|
SyntaxHighlighter.all(); |
|
}; |
|
|
|
//################################################### |
|
// Avatar (via twitter) |
|
//################################################### |
|
|
|
let avatar = function(a) { |
|
// Check for one-time execution. |
|
if (Array.some(document.styleSheets, function(e){return e.cssRules[0].selectorText == 'img.avatar';})) return; |
|
if (document.getElementsByClassName('avatar').length) return; |
|
|
|
// Add styles. |
|
GM_addStyle('img.avatar{height:24px;width:24px;margin-right:4px;}'); |
|
/* |
|
画像の読み込みが完了する前に IMG 要素がレンダリングされるため、 |
|
ロードに時間がかかると (そして twitter の場合、しばしば |
|
時間がかかる) 本来アバターが表示されるべき場所に、一時的とはいえ |
|
代替イメージが表示されてしまう。これがかなり見栄えを損なう。 |
|
そのため、ここでは、何らかの理由でアバター画像を表示できない場合 |
|
(ローディング中を含む)、要素自体を非表示にする方針で対処する。 |
|
*/ |
|
GM_addStyle('\ |
|
img:-moz-any(:-moz-loading, :-moz-broken, \ |
|
:-moz-user-disabled, :-moz-suppressed)\ |
|
{display: none !important;}' |
|
); |
|
|
|
// define twitter screen names |
|
var twitterScreenNames = { |
|
/* moug handle : twitter screen name */ |
|
'YU-TANG' : 'yu_tang_', |
|
'hatena' : 'hatena19', |
|
'マコ ' : 'Mako_Misaki', |
|
'kumatti' : 'kumatti1', |
|
'きぬよ&あさみ': 'kinuasa', |
|
'yamaV1.02β' : 'yamav102', |
|
'月' : 'honda0510' |
|
}; |
|
|
|
// Get list node. |
|
var e = GM_xpath({ |
|
path: a.path, |
|
all : true |
|
}); |
|
|
|
/* |
|
https://dev.twitter.com/docs/api/1/get/users/profile_image/:screen_name |
|
https://api.twitter.com/1/users/profile_image?screen_name=twitterapi&size=bigger |
|
bigger - 73px by 73px |
|
normal - 48px by 48px |
|
mini - 24px by 24px |
|
original - undefined. |
|
*/ |
|
|
|
for (var i = 0, l = e.length, s1, s2, tsn; i < l; ++i) { |
|
s1 = e[i].textContent; // ex. "きぬよ&あさみ" |
|
s1 = a.re.exec(s1); |
|
|
|
if (tsn = twitterScreenNames[s1[2]]) { |
|
s2 = e[i].innerHTML; // ex. "きぬよ&あさみ" |
|
s2 = a.re.exec(s2); |
|
e[i].innerHTML = |
|
( s2[1] || '' ) /* s2[1] が無い場合は undefined になるため */ |
|
+ '<img src="https://api.twitter.com/1/users/profile_image?screen_name=' |
|
+ tsn |
|
+ '&size=mini" class="avatar" alt="" title="アバター" />' |
|
+ '<span class="user-name">' + s2[2] + '</span>'; |
|
} |
|
} |
|
}; |
|
|
|
//################################################### |
|
// Post Id |
|
//################################################### |
|
let postId = function() { |
|
// Check for one-time execution. |
|
if (document.getElementById('1')) return; |
|
|
|
// Add styles. |
|
GM_addStyle('span.postId{margin-left:1em;} span.postId:before{content:"#";}'); |
|
|
|
// Get list node. |
|
var e = GM_xpath({ |
|
path: "//span[@class='small' and starts-with(., '投稿日時:')]", |
|
all : true |
|
}); |
|
|
|
for (var i = 0, l = e.length, span; i < l; ++i) { |
|
span = document.createElement("span"); |
|
span.className = "postId small"; |
|
span.innerHTML = (i + 1).toString(); |
|
e[i].parentNode.appendChild(span); |
|
} |
|
|
|
/* |
|
レス番の span 自体に id を付けると、#n で移動したときに |
|
微妙に上が見切れるので、別途テーブルに id を付与する |
|
*/ |
|
e = GM_xpath({ |
|
path: "//table[@class='response']", |
|
all : true |
|
}); |
|
|
|
for (i = 0, l = e.length; i < l; ++i) { |
|
e[i].id = (i + 1).toString(); |
|
} |
|
|
|
/* |
|
# アンカー指定のURLだった場合、初期状態ではアンカーが存在しないので |
|
JavaScript で移動させる。 |
|
アンカー指定の主なバリエーションは、下記の通り。 |
|
|
|
(1)NG: http://www.moug.net/faq/viewtopic.php#2?t=123456&highlight=hoge |
|
(2)Ok: http://www.moug.net/faq/viewtopic.php?t=123456&highlight=hoge |
|
(3)Ok: http://www.moug.net/faq/viewtopic.php?t=123456#2&highlight=hoge |
|
*/ |
|
var hash = window.location.hash; |
|
if (hash != '') { |
|
var re = /^#(\d+).*$/; |
|
if (re.test(hash)) { |
|
hash = hash.replace(re, '$1'); |
|
e = document.getElementById(hash); |
|
if (e) e.scrollIntoView(true); |
|
} |
|
} |
|
}; |
|
|
|
//################################################### |
|
// Keyboard Shortcut |
|
//################################################### |
|
|
|
/* |
|
kbsUtil - キーボード ショートカット ユーティリティ |
|
================================================== |
|
キーボード ショートカットに割り当てられた関数から参照されます。 |
|
moug のレイアウトは固定幅なので、要素の位置を毎回取得しなおすのは |
|
無駄です。そのため、本オブジェクト内で初回に取得した位置情報を保持 |
|
して、二回目以降はその情報を使いまわすこととします。 |
|
もしキーボード ショートカットが一度も使用されなければ、要素の情報 |
|
自体を取得しません。 |
|
|
|
shortcut - ショートカット オブジェクト |
|
====================================== |
|
フックしたいキーコード: 関数オブジェクト。 |
|
*/ |
|
switch (route) { |
|
case "viewforum": |
|
// ... |
|
case "viewtopic": |
|
var kbsUtil = new function(){ |
|
var _info = []; |
|
var _char = ''; |
|
var _prevChar = ''; |
|
var _ensureInit = function(){ |
|
var bg = document.getElementById('cheatSheetBg'); |
|
if (bg && bg.style.display != 'none') { |
|
bg.style.display = 'none'; |
|
} |
|
if (_info.length > 0) return; |
|
var tbl = GM_xpath({ |
|
path: "//table[@class='response']", |
|
all : true |
|
}); |
|
var re = /^投稿者:(?: |\s)(?:<img [^>]+>)?(.+)$/; |
|
var rect = tbl[0].getBoundingClientRect(); |
|
var offset = rect.top - tbl[0].offsetTop; |
|
for (var i = 0, l = tbl.length, poster, posterHTML; i < l; ++i) { |
|
poster = GM_xpath({ |
|
path: ".//th[contains(@class,'messageHeaderR')]/div/span[@class='small']", |
|
node: tbl[i] |
|
}); |
|
posterHTML = poster.innerHTML.replace(re, '$1'); |
|
poster = poster.textContent.replace(re, '$1'); |
|
_info.push({ |
|
index: i, |
|
element: tbl[i], |
|
offsetTop: tbl[i].offsetTop, |
|
top: Math.round(tbl[i].offsetTop + offset), |
|
posterHTML: posterHTML, |
|
poster: poster |
|
}); |
|
} |
|
}; |
|
/* |
|
_ensureInit(); |
|
ここだと初期化のタイミングが早すぎてバグる。各ショートカット実行時に初期化すること。 |
|
*/ |
|
var _getScreenTop = function(){ |
|
return Math.round(_info[0].top - _info[0].element.getBoundingClientRect().top); |
|
}; |
|
var _getCurrentPostInfo = function(){ |
|
var screenTopLine = _getScreenTop(); |
|
for (var i = 0, l = _info.length; i < l; ++i) { |
|
if (_info[i].top > screenTopLine){ |
|
return i > 0 ? _info[--i] : _info[0]; |
|
} |
|
} |
|
// ここに来るのは、画面上端より下の要素が見つからなかった場合なので、末尾要素情報を返す |
|
return _info[_info.length - 1]; |
|
}; |
|
this.next = function(){ |
|
_ensureInit(); |
|
var screenTopLine = _getScreenTop(); |
|
for (var i = 0, l = _info.length; i < l; ++i) { |
|
if (_info[i].top > screenTopLine) return _info[i].element; |
|
} |
|
// ここに来るのは、画面上端より下の要素が見つからなかった場合なので、フッターを返す |
|
return document.getElementById("footer"); |
|
}; |
|
this.prev = function(){ |
|
_ensureInit(); |
|
var screenTopLine = _getScreenTop(); |
|
for (var i = _info.length - 1, l = 0; i >= l; --i) { |
|
if (_info[i].top < screenTopLine) return _info[i].element; |
|
} |
|
// ここに来るのは、画面上端より上の要素が見つからなかった場合なので、ヘッダーを返す |
|
return document.getElementById("header"); |
|
}; |
|
this.nextSamePoster = function(){ |
|
_ensureInit(); |
|
var currentPostInfo = _getCurrentPostInfo(); |
|
var poster = currentPostInfo.poster; |
|
for (var i = currentPostInfo.index + 1, l = _info.length; i < l; ++i) { |
|
if (_info[i].poster == poster) return _info[i].element; |
|
} |
|
// ここに来るのは、見つからなかった場合なので、nullを返す |
|
return null; |
|
}; |
|
this.prevSamePoster = function(){ |
|
_ensureInit(); |
|
var currentPostInfo = _getCurrentPostInfo(); |
|
var poster = currentPostInfo.poster; |
|
for (var i = currentPostInfo.index - 1, l = 0; i >= l; --i) { |
|
if (_info[i].poster == poster) return _info[i].element; |
|
} |
|
// ここに来るのは、見つからなかった場合なので、nullを返す |
|
return null; |
|
}; |
|
this.first = function(){ |
|
_ensureInit(); |
|
return _info[0].element; |
|
}; |
|
this.last = function(){ |
|
_ensureInit(); |
|
return _info[_info.length - 1].element; |
|
}; |
|
this.showCheatSheet = function(){ |
|
const HEIGHT = 280, WIDTH = 200; |
|
var bg = document.getElementById('cheatSheetBg'); |
|
if (bg) { |
|
bg.style.display = (bg.style.display != 'none' ? 'none' : 'block'); |
|
} else { |
|
bg = document.createElement('div'); |
|
bg.id = 'cheatSheetBg'; |
|
bg.setAttribute( 'style', '\ |
|
position: fixed;\ |
|
top: 0;\ |
|
left: 0;\ |
|
right: 0;\ |
|
bottom: 0;\ |
|
background: none repeat scroll 0 0 rgba(0, 0, 0, 0.5);\ |
|
' ); |
|
bg.setAttribute( 'onClick', 'this.style.display = "none";' ); |
|
document.body.appendChild(bg); |
|
var cs = document.createElement('div'); |
|
cs.id = 'cheatSheet'; |
|
cs.innerHTML = '\ |
|
<p id="shortcut-title" style="text-align:center;">\ |
|
キーボード ショートカット</p><hr />\ |
|
<p><code class="shortcut-key">J</code>次の投稿</p>\ |
|
<p><code class="shortcut-key">K</code>前の投稿</p>\ |
|
<p><code class="shortcut-key">H</code>現在の投稿者の次の投稿</p>\ |
|
<p><code class="shortcut-key">L</code>現在の投稿者の前の投稿</p>\ |
|
<p><code class="shortcut-key">U</code>最初の投稿</p>\ |
|
<p><code class="shortcut-key">N</code>最後の投稿</p>\ |
|
<p><code class="shortcut-key">0</code>~<code class="shortcut-key">9</code>n 番目の投稿</p>\ |
|
<p style="margin-left:1em;font-size:x-small;">※ 連続入力で二桁番目の投稿指定</p>\ |
|
<p><code class="shortcut-key">?</code>ショートカットのヘルプ</p>\ |
|
'; |
|
cs.setAttribute( 'style', '\ |
|
position: fixed;\ |
|
width: ' + WIDTH + 'px;\ |
|
height: ' + HEIGHT + 'px;\ |
|
margin: auto;\ |
|
top: 0;\ |
|
right: 0;\ |
|
bottom: 0;\ |
|
left: 0;\ |
|
background: white;\ |
|
border-radius: 10px;\ |
|
padding: 10px;\ |
|
box-shadow: 2px 2px 10px #000;\ |
|
' ); |
|
bg.appendChild(cs); |
|
} |
|
}; |
|
this.__defineGetter__("char", function() { return _char; }); |
|
this.__defineSetter__("char", |
|
function(c) { |
|
[_prevChar, _char] = [_char, c]; |
|
if (c != '?') { |
|
var bg = document.getElementById('cheatSheetBg'); |
|
if (bg && bg.style.display != 'none') bg.style.display = 'none'; |
|
} |
|
} |
|
); |
|
this.__defineGetter__("prevChar", function() { return _prevChar; }); |
|
this.prevCharPressedTime = 0; |
|
this.getPostsLength = function(){_ensureInit();return _info.length}; |
|
this.getPostInfo = function(index){_ensureInit();return _info[index]}; |
|
}; |
|
|
|
var shortcut = { |
|
/* 0-9 : jump to post id */ |
|
'0': function(){ |
|
var jumpTo = kbsUtil.char; |
|
var end = Date.now(); |
|
var elapsed = end - kbsUtil.prevCharPressedTime; // ミリ秒での時間 |
|
if (elapsed < 1000) { // 1 秒以内の場合 |
|
if (/^\d$/.test(kbsUtil.prevChar)){ // 数値の場合 |
|
var i = Number(kbsUtil.prevChar + kbsUtil.char); |
|
var j = kbsUtil.getPostsLength(); |
|
if (i > 0 && i <= j){ |
|
jumpTo = i.toString(); |
|
} |
|
} |
|
} |
|
if (jumpTo != '0') { |
|
var e = kbsUtil.getPostInfo(Number(jumpTo) - 1); |
|
if (e) e.element.scrollIntoView(true); |
|
} |
|
}, |
|
/* "j" : next post */ |
|
'j': function(){ |
|
kbsUtil.next().scrollIntoView(true); |
|
}, |
|
/* "k" : prev post */ |
|
'k': function(){ |
|
kbsUtil.prev().scrollIntoView(true); |
|
}, |
|
/* "h" : next post by same poster */ |
|
'h': function(){ |
|
var post = kbsUtil.nextSamePoster(); |
|
if (post) post.scrollIntoView(true); |
|
}, |
|
/* "l" : prev post by same poster */ |
|
'l': function(){ |
|
var post = kbsUtil.prevSamePoster(); |
|
if (post) post.scrollIntoView(true); |
|
}, |
|
/* "u" : first post */ |
|
'u': function(){ |
|
kbsUtil.first().scrollIntoView(true); |
|
}, |
|
/* "n" : last post */ |
|
'n': function(){ |
|
kbsUtil.last().scrollIntoView(true); |
|
}, |
|
/* "?" : show cheat sheet */ |
|
'?': function(){ |
|
kbsUtil.showCheatSheet(); |
|
} |
|
}; |
|
for (let i = 49; i < 58; ++i) { shortcut[String.fromCharCode(i)] = shortcut['0'];} // '1'-'9' = '0' |
|
} |
|
|
|
let setKbShortcut = function() { |
|
document.addEventListener("keypress", function(e) { |
|
var pressed = String.fromCharCode(e.which); /* 大文字と小文字を意図的に揃えていないので、拾う側で注意 */ |
|
kbsUtil.char = pressed; |
|
|
|
// フォーカスが検索テキストボックスに有った場合は、入力の邪魔をしない。 |
|
// また Alt キーや Ctrl キーが押下されていた場合も、邪魔をしない。 |
|
if ( e.target.tagName.toLowerCase() == 'input' |
|
|| e.altKey |
|
|| e.ctrlKey |
|
|| e.metaKey ) |
|
return; |
|
|
|
if (shortcut.hasOwnProperty(pressed)) { |
|
shortcut[pressed](); |
|
} |
|
|
|
kbsUtil.prevCharPressedTime = Date.now(); |
|
}, true); |
|
|
|
window.addEventListener("scroll", function(e) { |
|
//GM_log("scroll event detected! "+window.pageXOffset+" "+window.pageYOffset); |
|
}, true); |
|
|
|
window.addEventListener("resize", function(e) { |
|
//GM_log("resize event detected! "+window.innerWidth +" "+window.innerHeight ); |
|
}, true); |
|
}; |
|
|
|
let putStyles4KbShortcut = function() { |
|
GM_addStyle('div#cheatSheet>p{line-height: 2em;}'); |
|
GM_addStyle('p#shortcut-title{font-weight:bold;}'); |
|
GM_addStyle('code.shortcut-key{margin: auto 0.8em; \ |
|
-moz-appearance:button;padding:3px 7px;}'); |
|
}; |
|
|
|
//################################################### |
|
// Main |
|
//################################################### |
|
|
|
switch (route) { |
|
case "viewforum": |
|
if (cfg.get('twitter_avatar')) { |
|
avatar({ |
|
path: "//td[position()=3]/div | //td[position()=4]", // [トピック] 列 || [最新投稿者] 列 |
|
re: /(\S+: |\S+:\s)?(\S+)/ |
|
}); |
|
} |
|
break; |
|
|
|
case "viewtopic": |
|
if (cfg.get('syntax_highlight')) |
|
syntaxHighlight(); |
|
|
|
if (cfg.get('twitter_avatar')) { |
|
avatar({ |
|
path: "//th[contains(@class,'messageHeaderR')]/div/span[@class='small']", |
|
re: /^(投稿者: |投稿者:\s)(.+)$/ |
|
}); |
|
} |
|
|
|
if (cfg.get('post_id')) |
|
postId(); |
|
|
|
if (cfg.get('shortcut')) { |
|
putStyles4KbShortcut(); |
|
setKbShortcut(); |
|
} |
|
break; |
|
|
|
//default: // search, profile, posting, ... |
|
// @include が ".../view*" になっているため、他は入ってこないはず。 |
|
// 投稿時 (posting) は、アクセス頻度が少ないと思われるため、非対応とします。 |
|
} |
|
|
|
} // スクリプト終了 |
実験してみました。
毎回呼ばれるようです。
しかも倍呼ばれています。
原因不明です。
Includeされるスクリプト(IncludeMe)がひとつ。
IncludeMeをインクルードするスクリプトが3つ。
(DoNothing1~3。これがユーザースクリプトです。)
IncludeMe
https://gist.github.com/raw/2214004/IncludeMe.js
DoNothing1
https://gist.github.com/raw/2214004/DoNothing1.user.js
DoNothing2
https://gist.github.com/raw/2214004/DoNothing2.user.js
DoNothing3
https://gist.github.com/raw/2214004/DoNothing3.user.js
IncludeMeを3回呼ぶかどうか試したところ、6回呼びました。