tree view for qwerty

あやしいわーるど@みさおの投稿をツリーで表示できます。スタック表示の方にもいくつか機能を追加できます。

当前为 2017-10-02 提交的版本,查看 最新版本

"use strict";
// zousan - A Lightning Fast, Yet Very Small Promise A+ Compliant Implementation
// https://github.com/bluejava/zousan
// Author: Glenn Crownover <[email protected]> (http://www.bluejava.com)
// Version 2.3.3
// License: MIT

/* jshint asi: true, browser: true */
/* global setImmediate, console */

(function(global){

		"use strict";

		var
			STATE_PENDING,					// These are the three possible states (PENDING remains undefined - as intended)
			STATE_FULFILLED = "fulfilled",		// a promise can be in.  The state is stored
			STATE_REJECTED = "rejected",		// in this.state as read-only

			_undefined,						// let the obfiscator compress these down
			_undefinedString = "undefined";		// by assigning them to variables (debatable "optimization")

		// See http://www.bluejava.com/4NS/Speed-up-your-Websites-with-a-Faster-setTimeout-using-soon
		// This is a very fast "asynchronous" flow control - i.e. it yields the thread and executes later,
		// but not much later. It is far faster and lighter than using setTimeout(fn,0) for yielding threads.
		// Its also faster than other setImmediate shims, as it uses Mutation Observer and "mainlines" successive
		// calls internally.
		// WARNING: This does not yield to the browser UI loop, so by using this repeatedly
		// 		you can starve the UI and be unresponsive to the user.
		// This is an even FASTER version of https://gist.github.com/bluejava/9b9542d1da2a164d0456 that gives up
		// passing context and arguments, in exchange for a 25x speed increase. (Use anon function to pass context/args)
		var soon = (function() {

				var	fq = [], // function queue;
					fqStart = 0, // avoid using shift() by maintaining a start pointer - and remove items in chunks of 1024 (bufferSize)
					bufferSize = 1024

				function callQueue()
				{
					while(fq.length - fqStart) // this approach allows new yields to pile on during the execution of these
					{
						try { fq[fqStart]() } // no context or args..
						catch(err) { if(global.console) global.console.error(err) }
						fq[fqStart++] = _undefined	// increase start pointer and dereference function just called
						if(fqStart == bufferSize)
						{
							fq.splice(0,bufferSize);
							fqStart = 0;
						}
					}
				}

				// run the callQueue function asyncrhonously, as fast as possible
				var cqYield = (function() {

						// This is the fastest way browsers have to yield processing
						if(typeof MutationObserver !== _undefinedString)
						{
							// first, create a div not attached to DOM to "observe"
							var dd = document.createElement("div");
							var mo = new MutationObserver(callQueue);
							mo.observe(dd, { attributes: true });

							return function() { dd.setAttribute("a",0); } // trigger callback to
						}

						// if No MutationObserver - this is the next best thing - handles Node and MSIE
						if(typeof setImmediate !== _undefinedString)
							return function() { setImmediate(callQueue) }

						// final fallback - shouldn't be used for much except very old browsers
						return function() { setTimeout(callQueue,0) }
					})();

				// this is the function that will be assigned to soon
				// it takes the function to call and examines all arguments
				return function(fn) {

						// push the function and any remaining arguments along with context
						fq.push(fn);

						if((fq.length - fqStart) == 1) // upon adding our first entry, kick off the callback
							cqYield();
					};

			})();

		// -------- BEGIN our main "class" definition here -------------

		function Zousan(func)
		{
			//  this.state = STATE_PENDING;	// Inital state (PENDING is undefined, so no need to actually have this assignment)
			//this.c = [];			// clients added while pending.   <Since 1.0.2 this is lazy instantiation>

			// If a function was specified, call it back with the resolve/reject functions bound to this context
			if(func)
			{
				var me = this;
				func(
					function(arg) { me.resolve(arg) },	// the resolve function bound to this context.
					function(arg) { me.reject(arg) })	// the reject function bound to this context
			}
		}

		Zousan.prototype = {	// Add 6 functions to our prototype: "resolve", "reject", "then", "catch", "finally" and "timeout"

				resolve: function(value)
				{
					if(this.state !== STATE_PENDING)
						return;

					if(value === this)
						return this.reject(new TypeError("Attempt to resolve promise with self"));

					var me = this; // preserve this

					if(value && (typeof value === "function" || typeof value === "object"))
					{
						try
						{
							var first = true; // first time through?
							var then = value.then;
							if(typeof then === "function")
							{
								// and call the value.then (which is now in "then") with value as the context and the resolve/reject functions per thenable spec
								then.call(value,
									function(ra) { if(first) { first=false; me.resolve(ra);}  },
									function(rr) { if(first) { first=false; me.reject(rr); } });
								return;
							}
						}
						catch(e)
						{
							if(first)
								this.reject(e);
							return;
						}
					}

					this.state = STATE_FULFILLED;
					this.v = value;

					if(me.c)
						soon(function() {
								for(var n=0, l=me.c.length;n<l;n++)
									resolveClient(me.c[n],value);
							});
				},

				reject: function(reason)
				{
					if(this.state !== STATE_PENDING)
						return;

					this.state = STATE_REJECTED;
					this.v = reason;

					var clients = this.c;
					if(clients)
						soon(function() {
								for(var n=0, l=clients.length;n<l;n++)
									rejectClient(clients[n],reason);
							});
					else
						if(!Zousan.suppressUncaughtRejectionError && global.console)
							global.console.log("You upset Zousan. Please catch rejections: ", reason,reason ? reason.stack : null)
				},

				then: function(onF,onR)
				{
					var p = new Zousan();
					var client = {y:onF,n:onR,p:p};

					if(this.state === STATE_PENDING)
					{
						 // we are pending, so client must wait - so push client to end of this.c array (create if necessary for efficiency)
						if(this.c)
							this.c.push(client);
						else
							this.c = [client];
					}
					else // if state was NOT pending, then we can just immediately (soon) call the resolve/reject handler
					{
						var s = this.state, a = this.v;
						soon(function() { // we are not pending, so yield script and resolve/reject as needed
								if(s === STATE_FULFILLED)
									resolveClient(client,a);
								else
									rejectClient(client,a);
							});
					}

					return p;
				},

				"catch": function(cfn) { return this.then(null,cfn); }, // convenience method
				"finally": function(cfn) { return this.then(cfn,cfn); }, // convenience method

				// new for 1.2  - this returns a new promise that times out if original promise does not resolve/reject before the time specified.
				// Note: this has no effect on the original promise - which may still resolve/reject at a later time.
				"timeout" : function(ms,timeoutMsg)
				{
					timeoutMsg = timeoutMsg || "Timeout"
					var me = this;
					return new Zousan(function(resolve,reject) {

							setTimeout(function() {
									reject(Error(timeoutMsg));	// This will fail silently if promise already resolved or rejected
								}, ms);

							me.then(function(v) { resolve(v) },		// This will fail silently if promise already timed out
									function(er) { reject(er) });		// This will fail silently if promise already timed out

						})
				}

			}; // END of prototype function list

		function resolveClient(c,arg)
		{
			if(typeof c.y === "function")
			{
				try {
						var yret = c.y.call(_undefined,arg);
						c.p.resolve(yret);
					}
				catch(err) { c.p.reject(err) }
			}
			else
				c.p.resolve(arg); // pass this along...
		}

		function rejectClient(c,reason)
		{
			if(typeof c.n === "function")
			{
				try
				{
					var yret = c.n.call(_undefined,reason);
					c.p.resolve(yret);
				}
				catch(err) { c.p.reject(err) }
			}
			else
				c.p.reject(reason); // pass this along...
		}

		// "Class" functions follow (utility functions that live on the Zousan function object itself)

		Zousan.resolve = function(val) { var z = new Zousan(); z.resolve(val); return z; }

		Zousan.reject = function(err) { var z = new Zousan(); z.reject(err); return z; }

		Zousan.all = function(pa)
		{
			var results = [ ], rc = 0, retP = new Zousan(); // results and resolved count

			function rp(p,i)
			{
				if(!p || typeof p.then !== "function")
					p = Zousan.resolve(p);
				p.then(
						function(yv) { results[i] = yv; rc++; if(rc == pa.length) retP.resolve(results); },
						function(nv) { retP.reject(nv); }
					);
			}

			for(var x=0;x<pa.length;x++)
				rp(pa[x],x);

			// For zero length arrays, resolve immediately
			if(!pa.length)
				retP.resolve(results);

			return retP;
		}

		// If this appears to be a commonJS environment, assign Zousan as the module export
		if(typeof module != _undefinedString && module.exports)		// jshint ignore:line
			module.exports = Zousan;	// jshint ignore:line

		// If this appears to be an AMD environment, define Zousan as the module export
		if(global.define && global.define.amd)
			global.define([], function() { return Zousan });

		// Make Zousan a global variable in all environments
		global.Zousan = Zousan;

		// make soon accessable from Zousan
		Zousan.soon = soon;

	})(typeof global != "undefined" ? global : this);	// jshint ignore:line

if (!window.Promise && typeof it == "undefined") {
	window.Promise = window.Zousan;

	if (!Promise.race) {
		Promise.race = function(promises) {
			return new Promise(function(resolve, reject) {
				promises.forEach(function(promise) { promise = promise.then ? promise : Promise.resolve(promise);
					promise.then(resolve).catch(reject);
				});
			});
		};
	}
}

if (!Object.assign) {
	Object.assign = function assign(target, source) { // eslint-disable-line no-unused-vars
		for (var index = 1, key, src; index < arguments.length; ++index) {
			src = arguments[index];

			for (key in src) {
				if (Object.prototype.hasOwnProperty.call(src, key)) {
					target[key] = src[key];
				}
			}
		}

		return target;
	};
}

if (!String.prototype.startsWith) {
	String.prototype.startsWith = function(start) {
		return this.lastIndexOf(start, 0) === 0;
	};
}
if (!String.prototype.endsWith) {
	Object.defineProperty(String.prototype, 'endsWith', {
		value: function (searchString, position) {
			var subjectString = this.toString();
			if (position === undefined || position > subjectString.length) {
				position = subjectString.length;
			}
			position -= searchString.length;
			var lastIndex = subjectString.indexOf(searchString, position);
			return lastIndex !== -1 && lastIndex === position;
		},
	});
}
if (!String.prototype.includes) {
	String.prototype.includes = function() {
		return String.prototype.indexOf.apply(this, arguments) !== -1;
	};
}
if (!String.prototype.trimRight) {
	String.prototype.trimRight = function() {
		return this.replace(/\s+$/, "");
	};
}

// element-closest | CC0-1.0 | github.com/jonathantneal/closest

if (typeof Element.prototype.matches !== 'function') {
	Element.prototype.matches = Element.prototype.msMatchesSelector || Element.prototype.mozMatchesSelector || Element.prototype.webkitMatchesSelector || Element.prototype.oMatchesSelector || function matches(selector) {
		var element = this;
		var elements = (element.document || element.ownerDocument).querySelectorAll(selector);
		var index = 0;

		while (elements[index] && elements[index] !== element) {
			++index;
		}

		return Boolean(elements[index]);
	};
}

if (typeof Element.prototype.closest !== 'function') {
	Element.prototype.closest = function closest(selector) {
		var element = this;

		while (element && element.nodeType === 1) {
			if (element.matches(selector)) {
				return element;
			}

			element = element.parentNode;
		}

		return null;
	};
}


/*exported on*/
function on(el, event, selector, callback) {
	el.addEventListener(event, function(e) {
		if (e.target.closest(selector)) {
			if (callback.handleEvent) {
				callback.handleEvent.call(callback, e);
			} else {
				callback(e);
			}
		}
	});
}

/*exported Env*/
var Env = (function() {
	var IS_EXTENSION = typeof chrome === 'object';
	return {
		IS_EXTENSION: IS_EXTENSION,
		IS_FIREFOX: typeof InstallTrigger !== 'undefined',
	};
})();

function NG(config) {
	var word = config.NGWord;
	var handle = config.NGHandle;

	if (config.useNG) {
		if (handle) {
			this.handle = new RegExp(handle);
			this.handleg = new RegExp(handle, "g");
		}
		if (word) {
			this.word = new RegExp(word);
			this.wordg = new RegExp(word, "g");
		}
	}

	this.isEnabled = !!(this.word || this.handle);
}

function Config() {}

Config.methods = function(storage) {
	function init() {
		this.ng = new NG(this);
	}
	var addID = function(config, type, id_or_ids, callback) {
		var target = "vanished" + type + "IDs";
		storage.get(target, function(IDs) {
			IDs = Array.isArray(IDs) ? IDs : [];

			var IDsToAdd = Array.isArray(id_or_ids) ? id_or_ids : [id_or_ids];
			IDsToAdd = IDsToAdd.filter(function(id) {
				return IDs.indexOf(id) === -1;
			});

			IDs = IDs.concat(IDsToAdd).sort(function(l, r) {
				return +r - l;
			});

			config[target] = IDs;
			storage.set(target, IDs, callback);
		});
	};
	var removeID = function(config, type, id) {
		var target = "vanished" + type + "IDs";
		storage.get(target, function(ids) {
			ids = Array.isArray(ids) ? ids : [];
			var index = ids.indexOf(id);
			if (index !== -1) {
				ids.splice(index, 1);
				config[target] = ids;
				if (ids.length) {
					storage.set(target, ids);
				} else {
					storage.remove(target);
				}
			}
		});
	};
	var clearIDs = function(config, type) {
		var target = "vanished" + type + "IDs";
		storage.remove(target);
		config[target] = [];
	};

	/** @param {String} id */
	var addVanishedMessage = function(id) {
		addID(this, "Message", id);
	};
	var removeVanishedMessage = function(id) {
		removeID(this, "Message", id);
	};
	var clearVanishedMessageIDs = function() {
		clearIDs(this, "Message");
	};

	/** @param {String} id */
	var addVanishedThread = function(id) {
		var config = this;
		return new Promise(function(resolve) {
			addID(config, "Thread", id, resolve);
		});
	};
	var removeVanishedThread = function(id) {
		removeID(this, "Thread", id);
	};
	var clearVanishedThreadIDs = function() {
		clearIDs(this, "Thread");
	};
	var clearVanish = function() {
		clearVanishedMessageIDs();
		clearVanishedThreadIDs();
	};
	var clear = function() {
		storage.clear();
		Object.assign(this, Config.prototype);
	};
	var update = function(items) {
		Object.keys(items).filter(function(key) {
			return typeof Config.prototype[key] === "undefined";
		}).forEach(function(key) {
			delete items[key];
		});
		storage.setAll(items);
		Object.assign(this, items);
	};

	var isTreeView = function() {
		return this.viewMode === "t";
	};

	return {
		init: init,
		addVanishedMessage: addVanishedMessage,
		removeVanishedMessage: removeVanishedMessage,
		clearVanishedMessageIDs: clearVanishedMessageIDs,
		addVanishedThread: addVanishedThread,
		removeVanishedThread: removeVanishedThread,
		clearVanishedThreadIDs: clearVanishedThreadIDs,
		clearVanish: clearVanish,
		clear: clear,
		update: update,
		isTreeView: isTreeView,
	};
};

Config.prototype = {
	treeMode: "tree-mode-ascii",
	toggleTreeMode: false,
	thumbnail: true,
	thumbnailPopup: true,
	popupAny: false,
	popupMaxWidth: "",
	popupMaxHeight: "",
	popupBestFit: true,
	threadOrder: "ascending",
	NGHandle: "",
	NGWord: "",
	useNG: true,
	NGCheckMode: false,
	spacingBetweenMessages: false,
	useVanishThread: true,
	vanishedThreadIDs: [], //扱い注意
	autovanishThread: false,
	utterlyVanishNGThread: false,
	useVanishMessage: false,
	vanishedMessageIDs: [],
	vanishMessageAggressive: false,
	utterlyVanishMessage: false,
	utterlyVanishNGStack: false,
	deleteOriginal: true,
	zero: true,
	accesskeyReload: "R",
	accesskeyV: "",
	keyboardNavigation: false,
	keyboardNavigationOffsetTop: "200",
	viewMode: "t",
	css: "",
	linkAnimation: true,
	shouki: true,
	closeResWindow: false,
	maxLine: "",
	openLinkInNewTab: false,
	characterEntity: true,
};

Config.storage = {};
Config.storage.chrome = {
	load: function() {
		var that = this;
		//eslint-disable-next-line no-undef
		return new Promise(function(resolve) {
			that.storage().get(Config.prototype, resolve);
		});
	},
	remove: function(key) {
		this.storage().remove(key);
	},
	set: function(key, value, callback) {
		var item = {};
		item[key] = value;
		this.storage().set(item, callback);
	},
	setAll: function(items) {
		this.storage().set(items);
	},
	clear: function() {
		this.storage().clear();
	},
	get: function(key, fun) {
		this.storage().get(key, function(item) {
			fun(item[key]);
		});
	},
	storage: function() {
		return chrome.storage.local;
	},
};
Config.storage.gm = {
	load: function() {
		return new Promise(function(resolve) {
			var config = Object.create(Config.prototype);
			var keys = Object.keys(Config.prototype);
			var i = keys.length;
			var key, value;
			while (i--) {
				key = keys[i];
				value = GM_getValue(key);
				if (value != null) {
					config[key] = JSON.parse(value);
				}
			}

			resolve(config);
		});
	},
	remove: function(key) {
		GM_deleteValue(key);
	},
	set: function(key, value, callback) {
		GM_setValue(key, JSON.stringify(value));

		if (callback) {
			callback();
		}
	},
	setAll: function(items) {
		for (var key in items) {
			this.set(key, items[key]);
		}
	},
	clear: function() {
		GM_listValues().forEach(GM_deleteValue);
	},
	get: function(key, fun) {
		fun(JSON.parse(GM_getValue(key, "null")));
	},
};

Config.load = function(storage) {
	storage = storage || Config.whichStorageToUse();

	return storage.load().then(function init(config) {
		Object.assign(config, Config.methods(storage));
		config.init();
		return config;
	});
};

Config.whichStorageToUse = function() {
	return typeof GM_getValue === "function" ? Config.storage.gm : Config.storage.chrome;
};

if (!window.__karma__) {
	Config.instance = Config.load();
}

/*global on, Env*/
function ConfigController(item) {
	this.item = item;
	var el = document.createElement("form");
	el.id = "config";
	this.el = el;

	var events = [
		"save",
		"clear",
		"close",
		"clearVanishThread",
		"clearVanishMessage",
		"addToNGWord",
	];
	for (var i = events.length - 1; i >= 0; i--) {
		var event = events[i];
		on(el, "click", "#" + event, this[event].bind(this));
	}

	on(el, 'keyup', '#quote-input', this.quotemeta.bind(this));

	this.render();
}
ConfigController.prototype = {
	$: function(selector) {
		return this.el.querySelector(selector);
	},
	$$: function(selector) {
		return Array.prototype.slice.call(this.el.querySelectorAll(selector));
	},
	render: function() {
		this.el.innerHTML = this.template();
		if (Env.IS_EXTENSION) {
			var close = this.$("#close");
			close.parentNode.removeChild(close);
		}
		this.restore();
	},
	template: function() {
		return '<style type="text/css">\
			<!--\
				li {\
					list-style-type: none;\
				}\
				#configInfo {\
					font-weight: bold;\
					font-style: italic;\
				}\
				legend + ul {\
					margin: 0 0 0 0;\
				}\
			-->\
			</style>\
			<fieldset>\
				<legend>設定</legend>\
				<fieldset>\
					<legend>表示</legend>\
					<ul>\
						<li><label><input type="radio" name="viewMode" value="t">ツリー表示</label></li>\
						<li><label><input type="radio" name="viewMode" value="s">スタック表示</label></li>\
					</ul>\
				</fieldset>\
				<fieldset>\
					<legend>共通</legend>\
					<ul>\
						<li><label><input type="checkbox" name="zero">常に0件リロード</label><em>(チェックを外しても「表示件数」は0のままなので手動で直してね)</em></li>\
						<li><label>未読リロードに使うアクセスキー<input type="text" name="accesskeyReload" size="1"></label></li>\
						<li><label>内容欄へのアクセスキー<input type="text" name="accesskeyV" size="1"></label></li>\
						<li><label><input type="checkbox" name="keyboardNavigation">jkで移動、rでレス窓開く</label><em><a href="@GF@#keyboardNavigation">chrome以外の人は説明を読む</a></em></li>\
						<ul>\
							<li><label>上から<input type="text" name="keyboardNavigationOffsetTop" size="4">pxの位置に合わせる</label></li>\
						</ul>\
						<li><label><input type="checkbox" name="closeResWindow">書き込み完了した窓を閉じる</label> <em><a href="@GF@#close-tab-in-firefox">firefoxは説明を読むこと</a></em><li>\
						<li><label><input type="checkbox" name="openLinkInNewTab">target属性の付いたリンクを常に新しいタブで開く</label></li>\
					</ul>\
				</fieldset>\
				<fieldset>\
					<legend>ツリーのみ</legend>\
					<ul style="display:inline-block">\
						<li><label><input type="checkbox" name="deleteOriginal">元の投稿を非表示にする</label>(高速化)</li>\
						<li>スレッドの表示順\
							<ul>\
								<li><label><input type="radio" name="threadOrder" value="ascending">古→新</label></li>\
								<li><label><input type="radio" name="threadOrder" value="descending">新→古</label></li>\
							</ul>\
						</li>\
						<li>ツリーの表示に使うのは\
							<ul>\
								<li><label><input type="radio" name="treeMode" value="tree-mode-css">CSS</label></li>\
								<li><label><input type="radio" name="treeMode" value="tree-mode-ascii">文字</label></li>\
							</ul>\
						</li>\
						<li><label><input type="checkbox" name="spacingBetweenMessages">記事の間隔を開ける</label></li>\
						<li><label><input type="text" name="maxLine" size="2">行以上は省略する</label></li>\
						<li><label><input type="checkbox" name="characterEntity">数値文字参照を展開</label> <em>(&#数字;が置き換わる)</em></li>\
						<li><label><input type="checkbox" name="toggleTreeMode">CSSツリー時にスレッド毎に一時的な文字/CSSの切り替えが出来るようにする</label></li>\
					</ul>\
					<fieldset style="display:inline-block">\
						<legend>投稿非表示設定</legend>\
						<ul>\
							<li><label><input type="checkbox" name="useVanishMessage">投稿非表示機能を使う</label> <em>使う前に<a href="@GF@#vanish">投稿非表示機能の注意点</a>を読むこと。</em><li>\
							<ul>\
								<li><span id="vanishedMessageIDs"></span>個の投稿を非表示中<input type="button" value="クリア" id="clearVanishMessage"></li>\
								<li><label><input type="checkbox" name="utterlyVanishMessage">完全に非表示</label></li>\
								<li><label><input type="checkbox" name="vanishMessageAggressive">パラノイア</label></li>\
							<ul>\
						</ul>\
					</fieldset>\
				</fieldset>\
				<fieldset>\
					<legend>スレッド非表示設定</legend>\
					<ul>\
						<li><label><input type="checkbox" name="useVanishThread">スレッド非表示機能を使う</label><li>\
						<ul>\
							<li><span id="vanishedThreadIDs"></span>個のスレッドを非表示中<input type="button" value="クリア" id="clearVanishThread"></li>\
							<li><label><input type="checkbox" name="utterlyVanishNGThread">完全に非表示</label></li>\
							<li><label><input type="checkbox" name="autovanishThread">NGワードを含む投稿があったら、そのスレッドを自動的に非表示に追加する(ツリーのみ)</label></li>\
						</ul>\
					</ul>\
				</fieldset>\
				<fieldset>\
					<legend>画像</legend>\
					<ul>\
						<li>\
							<label><input type="checkbox" name="thumbnail">小町と退避の画像のサムネイルを表示</label>\
							<ul>\
								<li>\
									<label><input type="checkbox" name="thumbnailPopup">ポップアップ表示</label>\
									<ul>\
										<li><label><input type="checkbox" name="popupBestFit">画面サイズに合わせる</label></li>\
										<li><label>最大幅:<input type="text" name="popupMaxWidth" size="5">px </label><label>最大高:<input type="text" name="popupMaxHeight" size="5">px <em>画面サイズに合わせない時の設定。空欄で原寸表示</em></label></li>\
									</ul>\
								</li>\
								<li><label><input type="checkbox" name="linkAnimation">描画アニメがある場合にリンクする</label></li>\
								<li><label><input type="checkbox" name="shouki">詳希(;゚Д゚)</label></li>\
							</ul>\
						</li>\
						<li><label><input type="checkbox" name="popupAny">小町と退避以外の画像も対象にする</label></li>\
					</ul>\
				</fieldset>\
				<fieldset>\
					<legend>NGワード</legend>\
					<ul>\
						<li><label><input type="checkbox" name="useNG">NGワードを使う</label>\
						<p>指定には正規表現を使う。以下簡易説明。複数指定するには|(縦棒)で"区切る"(先頭や末尾につけてはいけない)。()?*+[]{}^$.の前には\\を付ける。</p>\
						<li><table>\
							<tr>\
								<td><label for="NGHandle">ハンドル</label>\
								<td><input id="NGHandle" type="text" name="NGHandle" size="30"><em>投稿者とメールと題名</em>\
							<tr>\
								<td><label for="NGWord">本文</label>\
								<td><input id="NGWord" type="text" name="NGWord" size="30">\
							<tr><td><td><input id="quote-input" type="text" size="15" value=""> よく分からん人はここにNGワードを一つづつ入力して追加ボタンだ\
							<tr><td><td><input id="quote-output" type="text" size="15" readonly><input type="button" id="addToNGWord" value="本文に追加">\
						</table>\
						<li><label><input type="checkbox" name="NGCheckMode">NGワードを含む投稿を畳まず、NGワードをハイライトする</label>\
						<li><label><input type="checkbox" name="utterlyVanishNGStack">完全非表示</label>\
					</ul>\
				</fieldset>\
				<p>\
					<label>追加CSS<br><textarea name="css" cols="70" rows="5"></textarea></label>\
				</p>\
				<p>\
					<input type="submit" id="save" accesskey="s" value="保存(s)">\
					<input type="button" id="clear" style="float:right" value="デフォルトに戻す">\
					<input type="button" id="close" accesskey="c" value="閉じる(c)">\
					<span id="configInfo"></span>\
				</p>\
			</fieldset>'.replace(/@GF@/g, 'https://greasyfork.org/scripts/1971-tree-view-for-qwerty');
	},
	quotemeta: function() {
		var output = this.$('#quote-output');
		var input = this.$('#quote-input');
		output.value = ConfigController.quotemeta(input.value);
	},
	addToNGWord: function() {
		var output = this.$('#quote-output').value;
		if (!output.length) {
			return;
		}
		var word = this.$('#NGWord').value;
		if (word.length) {
			output = word + '|' + output;
		}
		this.$('#NGWord').value = output;
		this.$$('#quote-output, #quote-input').forEach(function(el) {
			el.value = '';
		});
	},
	save: function(e) {
		e.preventDefault();

		var items = {}, config = this.item;
		this.$$("input, select, textarea").forEach(function(el) {
			var k = el.name;
			var v = null;

			if (!k) {
				return;
			}

			switch (el.type) {
				case "radio":
					if (el.checked) {
						v = el.value;
					}
					break;
				case "text":
				case "textarea":
					v = el.value;
					break;
				case "checkbox":
					v = el.checked;
					break;
			}

			if (v !== null) {
				items[k] = v;
			}
		});
		config.update(items);

		this.info("保存しました。");
	},

	clear: function() {
		this.item.clear();
		this.restore();
		this.info("デフォルトに戻しました。");
	},

	close: function() {
		this.el.parentNode.removeChild(this.el);
		window.scrollTo(0, 0);
	},

	clearVanishThread: function() {
		this.item.clearVanishedThreadIDs();
		this.$("#vanishedThreadIDs").textContent = "0";
		this.info("非表示に設定されていたスレッドを解除しました。");
	},

	clearVanishMessage: function() {
		this.item.clearVanishedMessageIDs();
		this.$("#vanishedMessageIDs").textContent = "0";
		this.info("非表示に設定されていた投稿を解除しました。");
	},

	info: function(text) {
		clearTimeout(this.id);
		var info = this.$("#configInfo");
		info.textContent = text;
		this.id = setTimeout(function() {
			info.innerHTML = "";
		}, 5000);
	},

	restore: function restore() {
		var config = this.item;
		this.$("#vanishedThreadIDs").textContent = config.vanishedThreadIDs.length;
		this.$("#vanishedMessageIDs").textContent = config.vanishedMessageIDs.length;

		this.$$("input, select, textarea").forEach(function(el) {
			var name = el.name;
			if (!name) {
				return;
			}
			switch (el.type) {
				case "radio":
					el.checked = config[name] === el.value;
					break;
				case "text":
				case "textarea":
					el.value = config[name];
					break;
				case "checkbox":
					el.checked = config[name];
					break;
			}
		});
	},

};
ConfigController.quotemeta = function(str) {
	return (str + '').replace(/([()[\]{}|*+.^$?\\])/g, "\\$1");
};
/*global on, Env */

function identity(x) {
	return x;
}

function compose() {
	return Array.prototype.reduce.call(arguments, function(comp, fn) {
		return function() {
			return comp(fn.apply(null, arguments));
		};
	});
}

function memoize(fn) {
	var cache = {};
	return function(arg) {
		if (!cache.hasOwnProperty(arg)) {
			cache[arg] = fn(arg);
		}
		return cache[arg];
	};
}

function ajax(options) {
	options = options || {};
	var type = options.type || "GET";
	var url = options.url || location.href;
	var data = options.data || {};

	url = url.replace(/#.*$/, "");

	for (var key in data) {
		url += "&" + encodeURIComponent(key) + "=" + encodeURIComponent(data[key]);
	}

	url = url.replace(/[&?]{1,2}/, "?");

	return new Promise(function(resolve, reject) {
		var xhr = new XMLHttpRequest();

		xhr.open(type, url);
		xhr.overrideMimeType('text/html; charset=windows-31j');
		xhr.onload = function() {
			if (xhr.status === 200) {
				resolve(xhr.response);
			} else {
				reject(new Error(xhr.statusText));
			}
		};
		xhr.onerror = function() {
			reject(new Error("Network Error"));
		};
		xhr.send();
	});
}

function Post(id) {
	this.id = id;

	this.parent = null; // {Post}
	this.child = null; // {Post}
	this.next = null; // {Post}
	this.isNG = null;
}
Post.collectEssestialParts = function() {
	var nextFont = DOM.nextElement("FONT");
	var nextB = DOM.nextElement("B");
	var nextBlockquote = DOM.nextElement("BLOCKQUOTE");

	return function collectElements(a) {
		var header = nextFont(a);
		var name = nextB(header);
		var info = nextFont(name);
		var blockquote = nextBlockquote(info);
		var pre = blockquote.firstElementChild;
		var title = header.firstChild;
		var resButton = info.firstElementChild;
		var threadButton = info.lastElementChild;
		var threadUrl = threadButton.href;

		return {
			el: {
				anchor: a,
				blockquote: blockquote,
				pre: pre,
				title: title,
				name: name,
				info: info,
				resButton: resButton,
				posterButton: resButton.nextElementSibling,
				threadButton: threadButton,
			},
			name: name.innerHTML,
			title: title.innerHTML,
			text: pre.innerHTML,
			threadUrl: threadUrl,
			threadId: /&s=([^&]+)/.exec(threadUrl)[1],
		};
	};
};

Post.makePosts = function(context, hostname) {
	hostname = hostname || location.hostname;
	var posts = [];
	var as = context.querySelectorAll("a[name]");
	var font = DOM.nextElement("FONT");
	var b = DOM.nextElement("B");
	var blockquote = DOM.nextElement("BLOCKQUOTE");

	for (var i = 0, len = as.length; i < len; i++) {
		var a = as[i];
		var post = new Post(a.name);
		posts.push(post);

		var header = font(a);

		post.title = header.firstChild.innerHTML;
		var named = b(header);
		post.name = named.innerHTML;

		var info = font(named);
		post.date = info.firstChild.nodeValue.trim().slice(4);//「投稿日:」削除
		post.resUrl = info.firstElementChild.href;
		post.threadUrl = info.lastElementChild.href;
		post.threadId = /&s=([^&]+)/.exec(post.threadUrl)[1];
		if (info.childElementCount === 3) {
			post.posterUrl = info.firstElementChild.nextElementSibling.href;
		} else {
			post.posterUrl = null;
		}

		var body = blockquote(info);
		var pre = body.firstElementChild;
		var env = font(pre);

		if (env) {
			post.env = env.firstChild.innerHTML; // font > i > env
		}

		var text = pre.innerHTML.replace(/<\/?font[^>]*>/ig, "")
			.replace(/\r\n?/g, "\n")
			.slice(0, -1);

		if (text.includes("&lt;A")) {
			text = text.replace(
				//        "       </A>
				//firefox %22    %3C\/A%3E
				//chrome  &quot; &lt;\/A&gt;
				//opera   &quot; <\/A>
				/&lt;A href="<a href="(.*)(?:%22|&quot;)"( target="link"(?: rel="noreferrer noopener")?)>\1"<\/a>\2&gt;<a href="\1(?:%3C\/A%3E|&lt;\/A&gt;|<\/A>)"\2>\1&lt;\/A&gt;<\/a>/g,
				'<a href="$1"$2>$1</a>'
			);
		}

		post.text = text;

		var reference = /\n\n<a href="h[^"]+&amp;s=((?!0)\d+)&amp;r=[^"]+">参考:([^<]+)<\/a>$/.exec(text);
		if (!reference) {
			reference = /\n\n<a href="#((?!0)\d+)">参考:([^<]+)<\/a>$/.exec(text);
		}

		if (reference) {
			post.setParentId(reference[1]);
			post.setParentDate(reference[2]);
			text = text.slice(0, reference.index);
		}

		var url = /\n\n<[^<]+<\/a>$/.exec(text);
		if (url) {
			text = text.slice(0, url.index);
		}
		if (!text.includes("<") && text.includes(":")) {
			post.text = Post.relinkify(text, /misao/.test(hostname) ? ' rel="noreferrer noopener"' : "") +
				(url ? url[0] : "") + (reference ? reference[0] : "");
		}
	}

	Post.sortByTime(posts);

	return posts;
};
// 新しいのが先
Post.sortByTime = function(posts) {
	if (posts.length >= 2 && (+posts[0].id) < (+posts[1].id)) {
		posts.reverse();
	}
};
Post.byID = function(l, r) {
	return +l.id - +r.id;
};
Post.relinkify1stMatching = function(_, rel, url) {
	rel = rel || "";
	return Post.relinkify(url, rel);
};
Post.relinkify = function(url, rel) {
	var replacer = '<a href="$&" target="link"' + rel + '>$&</a>';

	return url.replace(/(?:https?|ftp|gopher|telnet|whois|news):\/\/[\x21-\x7e]+/ig, replacer);
};
Post.checkNG = function(ng, post) {
	var isNG = false;

	if (ng.word) {
		isNG = ng.word.test(post.text);
	}
	if (!isNG && ng.handle) {
		isNG = isNG || ng.handle.test(post.name);
		isNG = isNG || ng.handle.test(post.title);
	}

	post.isNG = isNG;

	return post;
};
Post.prototype = {
	id: "", // {string} /^\d+$/
	title: " ", // {string}
	name: " ", // {string}
	date: "", // {string}
	resUrl: "", // {string}
	threadUrl: "", // {string}
	threadId: "", // {string}
	posterUrl: "", // {string}
	// null: 親なし
	// undefined: 不明
	// string: ID 0から始まらない数字の文字列
	/** @type {(undefined|?string)} */
	parentId: null,
	parentDate: "", // {string}
	text: "", // {string}

	showAsIs: false, // {boolean}
	rejectLevel: 0, // {number}
	isRead: false, // {boolean}

	isOP: function() {
		return this.id === this.threadId;
	},
	getText: function() {
		if (this.hasSameDate()) {
			return this.text.slice(0, this.text.lastIndexOf("\n\n"));//参考と空行を除去
		}

		return this.text;
	},
	hasSameDate: function() {
		return this.parent && this.parent.date === this.parentDate;
	},
	computeQuotedText: function() {
		var lines = this.text
			.replace(/&gt; &gt;.*\n/g, "")
			//target属性がないのは参考リンクのみ
			.replace(/<a href="[^"]+">参考:.*<\/a>/i, "")
			.replace(
				/<a href="[^"]+" target="link"( rel="noreferrer noopener")?>([^<]+)<\/a>/ig,       //<A href=¥S+ target=¥"link¥">(¥S+)<¥/A>
				Post.relinkify1stMatching
			)
			.replace(/\n/g, "\n&gt; ");
		lines = ("&gt; " + lines + "\n")
			.replace(/\n&gt;[ \n\r\f\t]+\n/g, "\n")
			.replace(/\n&gt;[ \n\r\f\t]+\n$/, "\n");
		return lines;
	},
	textCandidate: function() {
		var text = this.text
			.replace(/^&gt; (.*\n?)|^.*\n?/mg, "$1")
			.replace(/\n$/, "")
			.replace(/^[ \n\r\f\t]*$/mg, "$&\n$&");

		//TODO 引用と本文の間に一行開ける
		//text = text.replace(/((?:&gt; .*\n)+)(.+)/, "$1\n$2"); //replace(/^(?!&gt; )/m, "\n$&");

		return text;// + "\n\n";
	},
	textCandidateLooksValid: function() {
		return this.getText().replace(/^&gt; .*/mg, "").trim() !== "";
	},
	textBonus: 2,
	dateCandidate: function() {
		return this.parentDate;
	},
	dateCandidateLooksValid: function(candidate) {
		return /^\d{4}\/\d{2}\/\d{2}\(.\)\d{2}時\d{2}分\d{2}秒$/.test(candidate);
	},
	dateBonus: 100,
	hasQuote: function() {
		return (/^&gt; /m).test(this.text);
	},
	mayHaveParent: function() {
		return this.isRead && !this.isOP() && this.hasQuote();
	},
	setParentId: function(parentId) {
		if (+this.id > parentId) {
			this.parentId = parentId;
		}
	},
	setParentDate: function(parentDate) {
		this.parentDate = parentDate;
	},
};

var ImaginaryPostPrototype = {
	__proto__: Post.prototype,
	/**
	 * @param {Post} child
	 */
	setFields: function(child) {
		this.id = child.parentId;

		this.parent = null;
		this.child = child;
		this.next = null;
		this.isNG = null;

		this.threadId = child.threadId;
		this.threadUrl = child.threadUrl;

		this.parentId = this.isOP() ? null : undefined;

		if (this.id) {
			this.setResUrl();
		}
	},
	calculate: function(property) {
		var value, child = this.child;
		var getCandidate = property + "Candidate";

		if (child.next) {
			var rank = Object.create(null), max = 0, candidate;
			var validates = getCandidate + "LooksValid";
			var bonus = this[property + "Bonus"];

			do {
				candidate = child[getCandidate]();
				rank[candidate] = ++rank[candidate] || 1;
				if (child[validates](candidate)) {
					rank[candidate] += bonus;
				}
			} while ((child = child.next));

			for (candidate in rank) {
				var number = rank[candidate];
				if (max < number) {
					max = +number;
					value = candidate;
				}
			}
		} else {
			value = child[getCandidate]();
		}

		return Object.defineProperty(this, property, {value: value})[property];
	},
	getText: function() {
		return this.text;
	},
	isRead: true,
	setResUrl: function() {
		this.resUrl = this.child.resUrl.replace(/(\?|&)s=\d+/, "$1s=" + this.id);
	},
};
Object.defineProperty(ImaginaryPostPrototype, "text", {
	get: function() {
		return this.calculate("text");
	},
});

function MergedPost(child) {
	this.setFields(child);
	this.name = child.title.replace(/^>/, "");
}
MergedPost.prototype = Object.create(ImaginaryPostPrototype, {
	date: {
		get: function() {
			return this.calculate("date");
		},
	},
});

function GhostPost(child) {
	this.setFields(child);
}
GhostPost.prototype = Object.create(ImaginaryPostPrototype);
GhostPost.prototype.date = "?";

function Thread(config, postParent, id) {
	this.config = config;
	this.postParent = postParent;
	this.posts = [];
	this.id = id;
	this.postCount = 0;
	this.isNG = false;
}
Thread.connect = function(allPosts) {
	var lastChild = Object.create(null);

	return function connect(roots, post) {
		allPosts[post.id] = post;
		var parentId = post.parentId;
		// parentIdは自然数の文字列かnull
		if (parentId) {
			var parent = allPosts[parentId];
			if (parent) {
				var child = lastChild[parentId];
				if (child) {
					child.next = post;
				} else {
					parent.child = post;
				}
			} else {
				parent = new MergedPost(post);
				allPosts[parentId] = parent;
				roots.push(parent);
			}
			post.parent = parent;
			lastChild[parentId] = post;
		} else {
			roots.push(post);
		}
		return roots;
	};
};

Thread.computeRejectLevelForRoot = function(vanishedMessageIDs, postParent, id, level) {
	if (!id || level === 0) {
		return 0;
	}

	if (vanishedMessageIDs.indexOf(id) > -1) {
		return level;
	}

	return Thread.computeRejectLevelForRoot(vanishedMessageIDs, postParent, postParent.find(id), level - 1);
};
Thread.setRejectLevel = function(vanishedMessageIDs, post, generation) {
	var rejectLevel = 0;

	if (vanishedMessageIDs.indexOf(post.id) > -1) {
		rejectLevel = 3;
	} else if (generation > 0) {
		rejectLevel = generation;
	}

	post.rejectLevel = rejectLevel;

	var child = post.child;
	var next = post.next;

	if (child) {
		Thread.setRejectLevel(vanishedMessageIDs, child, rejectLevel - 1);
	}

	if (next) {
		Thread.setRejectLevel(vanishedMessageIDs, next, generation);
	}
};

Thread.prototype = {
	addPost: function(post) {
		this.posts.push(post);

		if (post.isNG) {
			this.isNG = true;
		}
	},
	makeRoots: function(parentIDs, allPosts, roots) {
		return roots.reduce(function(roots, post) {
			var root = post;

			if (post.mayHaveParent()) {
				// parentID = 自然数の文字列 || null || undefined
				var parentID = parentIDs[post.id];
				var parent = allPosts[parentID];

				if (parent) {
					root = null;
					post.parentId = parentID;
					post.parent = parent;
					post.next = parent.child;
					parent.child = post;
				} else if (parentID !== null) { // string || undefined
					post.parentId = parentID;
					var ghost = new GhostPost(post);
					post.parent = ghost;

					if (parentID) { // string
						allPosts[parentID] = ghost;
					}

					root = ghost;
				}
			}

			if (root) {
				roots.push(root);
			}

			return roots;
		}, []);
	},
	computeRoots: function(threshold) {
		var parentIDs = this.posts
		.filter(function(post) {
			return post.parentId !== null;
		}).map(function(post) {
			return post.parentId;
		});

		var pParentIDHash = this.postParent.findAll(parentIDs, this.id);

		if (pParentIDHash.then) {
			return pParentIDHash.then(this.doComputeRoots.bind(this, threshold));
		} else {
			return this.doComputeRoots(threshold, pParentIDHash);
		}
	},
	doComputeRoots: function(threshold, parentIDHash) {
		var allPosts = Object.create(null);
		var roots = this.posts.reduceRight(Thread.connect(allPosts), []);
		roots.sort(Post.byID);

		roots = this.makeRoots(parentIDHash, allPosts, roots);

		this.postCount = this.posts.length;

		if (this.config.useVanishMessage) {
			var smallestMessageID = this.getSmallestMessageID(allPosts);

			if (smallestMessageID <= threshold) {
				roots = this.processVanish(roots);
			}

			if (this.config.utterlyVanishMessage) {
				roots = this.processUtterlyVanish(roots);
			}
		}

		return roots;
	},
	getSmallestMessageID: function(allPosts) {
		return Object.keys(allPosts).sort(this.byNumber)[0];
	},
	byNumber: function(l, r) {
		return l - r;
	},
	processVanish: function(roots) {
		var vanishedMessageIDs = this.config.vanishedMessageIDs;
		var computeRejectLevelForRoot = Thread.computeRejectLevelForRoot;
		var setRejectLevel = Thread.setRejectLevel;
		var postParent = this.postParent;

		for (var i = roots.length - 1; i >= 0; i--) {
			var root = roots[i];
			var child = root.child;
			var id = root.id;

			if (id) {
				root.rejectLevel = computeRejectLevelForRoot(vanishedMessageIDs, postParent, id, 3);
			}

			if (child) {
				setRejectLevel(vanishedMessageIDs, child, root.rejectLevel - 1);
			}
		}

		return roots;
	},
	processUtterlyVanish: function(roots) {
		var newRoots = [];
		var vanished = 0;
		function drop(post, isRoot) {
			var child = post.child;
			var next = post.next;
			var rejectLevel = post.rejectLevel;
			var isRead = post.isRead;

			if (child) {
				child = drop(child, false);
			}

			if (next) {
				next = drop(next, false);
			}

			if (!child && isRead) {
				return next;
			}

			if (rejectLevel && !isRead) {
				vanished++;
			}

			post.child = child;
			post.next = next;

			if (isRoot && rejectLevel === 0) {
				newRoots.push(post);
			} else if (rejectLevel === 1 && child) {
				newRoots.push(child);
			}

			return rejectLevel === 3 ? next : post;
		}

		for (var i = roots.length - 1; i >= 0; i--) {
			drop(roots[i], true);
		}

		this.postCount -= vanished;

		return newRoots.sort(Post.byID);
	},
	getDate: function() {
		return this.posts[0].date;
	},
	getNumber: function() {
		return this.postCount;
	},
	getID: function() {
		return this.id;
	},
	getURL: function() {
		return this.posts[0].threadUrl;
	},
};

var Posts = {
	checkCharacterEntity: function(config, data) {
		var state = data.state;
		var post = data.post;

		state.hasCharacterEntity = /&amp;#(?:\d+|x[\da-fA-F]+);/.test(data.value);
		state.expandCharacterEntity = state.hasCharacterEntity && (post.hasOwnProperty("characterEntity") ? post.characterEntity : config.characterEntity);

		return data;
	},
	characterEntity: function(data) {
		var state = data.state;

		if (state.expandCharacterEntity) {
			var iter = document.createNodeIterator(data.value, NodeFilter.SHOW_TEXT, null, false);  //operaは省略可能な第3,4引数も渡さないとエラーを吐く
			var node;
			while ((node = iter.nextNode())) {
				node.data = node.data.replace(/&#(\d+|x[0-9a-fA-F]+);/g, Posts.replaceCharacterEntity);
			}
		}

		return data;
	},
	replaceCharacterEntity: function(str, p1) {
		return String.fromCharCode(p1[0] === "x" ? parseInt(p1.slice(1), 16) : p1);
	},
	makeText: function(data) {
		//終わりの空行引用は消してレスする人がいる
		//引用の各行に空白を追加する人がいる
		var post = data.post;
		var text = post.getText();
		var parent = post.parent ? post.parent.computeQuotedText() : "";

		if (post.showAsIs || post.isNG) {
			text = Posts.markQuote(text, parent);
		} else {
			if (text.startsWith(parent)) {
				text = text.slice(parent.length);
			} else {
				//整形して
				parent = Posts.trimRights(parent);
				text = Posts.trimRights(text);

				//もう一度
				if (text.startsWith(parent)) {
					text = text.slice(parent.length);
				} else {
					//深海式レスのチェック
					var parent2 = parent.split("\n").filter(function(line) {
						return !line.startsWith("&gt; &gt; ");
					}).join("\n");
					if (text.startsWith(parent2)) {
						text = text.slice(parent2.length);
					} else {
						text = Posts.markQuote(text, parent);
					}
				}
			}

			//全角空白も\sになる
			//空白のみの投稿が空投稿になる
			text = text.trimRight().replace(/^\s*\n/, "");

			if (text.length === 0) {
				text = '<span class="note">(空投稿)</span>';
			}
		}

		data.value = text;

		return data;
	},
	checkThumbnails: function(data) {
		data.state.mayHaveThumbnails = data.value.includes('<a');

		return data;
	},
	putThumbnails: function(config) {
		if (!config.thumbnail) {
			return identity;
		}

		var thumbnail = new Thumbnail(config);
		return function(data) {
			if (data.state.mayHaveThumbnails) {
				thumbnail.register(data.value);
			}

			return data;
		};
	},
	checkNGIfRead: function(ng) {
		if (!ng.isEnabled) {
			return identity;
		}

		return function(data) {
			var post = data.post;

			if (post.isRead) {
				Post.checkNG(ng, post);
			}

			return data;
		};
	},
	markNG: function(reg) {
		if (!reg) {
			return identity;
		}
		if (!reg.global) {
			throw new Error();
		}

		return function(data) {
			if (reg && data.post.isNG) {
				data.value = data.value.replace(reg, "<mark class='NGWordHighlight'>$&</mark>");
			}

			return data;
		};
	},
	markNGHeader: function(reg) {
		if (reg && !reg.global) {
			throw new Error();
		}

		return function(value) {
			return value.replace(reg, "<mark class='NGWordHighlight'>$&</mark>");
		};
	},
	markQuote: function(text, parent) {
		var parentLines = parent.split("\n");
		parentLines.pop();
		var lines = text.split("\n");
		var i = Math.min(parentLines.length, lines.length);

		while (i--) {
			lines[i] = '<span class="quote' +
				(parentLines[i] === lines[i] ? '' : ' modified') +
				'">' + lines[i] + '</span>';
		}

		return lines.join("\n");
	},
	trimRights: function(string) {
		return string.replace(/^.+$/gm, function(str) {
			return str.trimRight();
		});
	},
	truncate: function(config, data) {
		var post = data.post;

		if (!config.maxLine || post.showAsIs) {
			return data;
		}

		var text = data.value;
		var maxLine = +config.maxLine;
		var lines = text.split("\n");
		var length = lines.length;

		if (length > maxLine) {
			var truncation = post.hasOwnProperty("truncation") ? post.truncation : true;
			var label;

			if (truncation) {
				lines[maxLine] = '<span class="truncation">' + lines[maxLine];
				text = lines.join("\n") + "\n</span>";
				label = '以下' + (length - maxLine) + '行省略';
			} else {
				text += '\n';
				label = '省略する';
			}

			text += '(<a href="javascript:;" class="toggleTruncation note">' + label + '</a>)';
		}

		data.value = text;

		return data;
	},
	prependExtension: function(data) {
		if (data.state.extension) {
			return data.state.extension.text(data);
		} else {
			return data;
		}
	},
	createDText: function(treeMode) {
		var classes = "text text_" + treeMode;
		return function(data) {
			var post = data.post;

			var dText = document.createElement("div");
			dText.className = classes + (post.isRead ? " read" : "");
			dText.innerHTML = data.value;

			data.value = dText;

			return data;
		};
	},
	unfoldButton: function(data) {
		var rejectLevel = data.post.rejectLevel;
		var reasons = [];

		if (rejectLevel) {
			reasons.push([null, "孫", "子", "個"][rejectLevel]);
		}

		if (data.post.isNG) {
			reasons.push("NG");
		}

		return '<a class="showMessageButton" href="javascript:;">' + reasons.join(",") + '</a>';
	},
	hide: function(config) {
		var notCheckMode = !config.NGCheckMode;

		return function(data) {
			var post = data.post;

			data.state.hide = (post.isNG && notCheckMode) || post.rejectLevel;

			return data;
		};
	},
	headerContents: function(state, config, post, name, title) {
		var resUrl = post.resUrl ? 'href="' + post.resUrl + '" ' : '';
		var vanish;
		if (post.rejectLevel === 3) {
			vanish = ' <a href="javascript:;" class="cancelVanishedMessage">非表示を解除</a>';
		} else if (config.useVanishMessage) {
			vanish = ' <a href="javascript:;" class="toggleMessage">消</a>';
		} else {
			vanish = "";
		}
		var header = '<a ' + resUrl + 'class="res" target="link">■</a>'
			+ '<span class="message-info">'
			+ ((title === '> ' || title === ' ') && name === ' '
				? ""
				: '<strong>' + title + '</strong> : <strong>' + name + '</strong> #'
			)
			+ post.date + '</span>'
			+ (resUrl && ' <a ' + resUrl + ' target="link">■</a>')
			+ vanish
			+ (state.hide ? ' <a href="javascript:;" class="fold">畳む</a>' : "")
			+ (post.posterUrl ? ' <a href="' + post.posterUrl + '" target="link">★</a>' : '')
			+ (state.hasCharacterEntity ? ' <a href="javascript:;" class="characterEntity' + (state.expandCharacterEntity ? ' characterEntityOn' : '' ) + '">文字参照</a>' : "")
			+ ' <a href="'
			+ post.threadUrl
			+ '" target="link">◆</a>';

		return header;
	},
};

function AbstractPosts() {}
AbstractPosts.prototype = {
	init: function(item, el) {
		this.el = el || document.createElement("div");
		this.el.className = "messages";
		this.item = item;
	},
	getContainer: function() {
		return this.el;
	},
	render: function(config) {
		if (this.pre) {
			this.pre();
		}

		var roots = this.item;
		var maker = this.messageMaker(config);

		for (var i = 0, length = roots.length; i < length; i++) {
			this.doShowPosts(config, maker, roots[i], 1);
		}
		return this.el;
	},
	doShowPosts: function(config, maker, post, depth) {
		var dm = maker(post, depth);
		var dc = this.getContainer(post, depth);
		dc.appendChild(dm);

		if (post.child) {
			this.doShowPosts(config, maker, post.child, depth + 1);
		}
		if (post.next) {
			this.doShowPosts(config, maker, post.next, depth);
		}
	},
	checker: function(config) {
		var functions = [
			Posts.hide(config),
			Posts.checkNGIfRead(config.ng),
		];

		return compose.apply(null, functions);
	},
	text: function(config) {
		var markNG = Posts.markNG(config.ng.wordg);
		var putThumbnails = Posts.putThumbnails(config);
		var truncate = Posts.truncate.bind(Posts, config);
		var checkCharacterEntity = Posts.checkCharacterEntity.bind(Posts, config);

		return compose(
			putThumbnails,
			Posts.characterEntity,
			Posts.createDText(this.mode),
			Posts.prependExtension,
			truncate,
			markNG,
			checkCharacterEntity,
			Posts.checkThumbnails,
			Posts.makeText
		);
	},
	unfoldButton: Posts.unfoldButton,
	headerContents: Posts.headerContents,
	div: function(clazz, content) {
		var el = document.createElement("div");

		el.className = clazz;
		el.innerHTML = content;

		return el;
	},
	header: function(config) {
		var ng = config.ng;
		var markNGHeader = ng.handleg ? Posts.markNGHeader(ng.handleg) : identity;
		var classes = "message-header message-header_" + this.mode;

		return function(data) {
			var post = data.post;
			var state = data.state;
			var title = post.title;
			var name = post.name;

			if (post.isNG) {
				title = markNGHeader(title);
				name = markNGHeader(name);
			}

			var header = this.headerContents(state, config, post, name, title);

			return this.div(classes, header);
		}.bind(this);
	},
	env: function(data) {
		if (!data.post.env) {
			return null;
		}

		var env = '<span class="env">(' + data.post.env.replace(/<br>/, "/") + ')</span>';

		return this.div("extra extra_" + this.mode, this.doEnv(env, data));
	},
	doEnv: identity,
	message: function(header, text, env) {
		var el = document.createElement("div");

		el.appendChild(header);
		el.appendChild(text);

		if (env) {
			el.appendChild(env);
		}

		el.className = "message message_" + this.mode;

		return el;
	},
	messageMaker: function(config) {
		var checker = this.checker(config);
		var text = this.text(config);
		var header = this.header(config);

		return function(post, depth) {
			var dMessage;

			var data = checker({
				post: post,
				value: null,
				state: {
					depth: depth,
				},
			});

			var state = data.state;

			if (state.hide && !post.show) {
				dMessage = this.div("showMessage showMessage_" + this.mode, this.unfoldButton(data));
			} else {
				data = text(data);
				var dText = data.value;
				var dHeader = header(data);
				var dEnv = this.env(data);

				dMessage = this.message(dHeader, dText, dEnv);
			}

			if (config.spacingBetweenMessages) {
				this.setSpacer(dMessage, state.extension);
			}

			if (this.setMargin) {
				this.setMargin(dMessage, state.depth);
			}

			dMessage.id = post.id;
			dMessage.dataset.id = post.id;
			dMessage.post = post;

			return dMessage;
		}.bind(this);
	},
};

function CSSView() {
	this.mode = "tree-mode-css";
	this.containers = null;
	this.pre = function() {
		this.containers = [{dcontainer: this.el}];
	};

	this.border = function(depth) {
		var left = depth + 0.5;

		return DOM('<div class="border outer" style="left:' + left + 'rem">' +
			'<div class="border inner" style="left:-' + left + 'rem">' +
			'</div></div>');
	};

	this.getContainer = function(post, depth) {
		var containers = this.containers;
		var container = containers[containers.length - 1];

		if ("lastChildID" in container && container.lastChildID === post.id) {
			containers.pop();
			container = containers[containers.length - 1];
		}

		var child = post.child;
		if (child && child.next) {
			var lastChild = child;
			do {
				lastChild = lastChild.next;
			} while (lastChild.next);

			var dout = this.border(depth);
			container.dcontainer.appendChild(dout);
			container = {lastChildID: lastChild.id, dcontainer: dout.firstChild};
			containers.push(container);
		}

		return container.dcontainer;
	};

	this.setSpacer = function(el) {
		el.classList.add("spacing");
	};

	this.setMargin = function(el, depth) {
		el.style.marginLeft = depth + 'rem';
	};
}
CSSView.prototype = Object.create(AbstractPosts.prototype);

function ASCIIView() {
	this.mode = "tree-mode-ascii";

	function wrapTree(tag, tree) {
		return '<' + tag + ' class="a-tree">' + tree + '</' + tag + '>';
	}

	function computeExtension(config, post) {
		var forHeader, forText, init;
		var utterlyVanishMessage = config.utterlyVanishMessage;
		var hasNext = post.next;
		var tree = [];
		var parent = post;

		while ((parent = parent.parent)) {
			if (utterlyVanishMessage && parent.rejectLevel) {
				break;
			}
			tree.push(parent.next ? "|" : " ");
		}
		init = tree.reverse().join("");

		if (post.isOP()) {
			forHeader = " ";
		} else {
			forHeader = init + (hasNext ? '├' : '└');
		}
		forText = init + (hasNext ? '|' : ' ') + (post.child ? '|' : ' ');

		return {header: forHeader, text: forText};
	}

	this.extension = function(config, data) {
		var extension = computeExtension(config, data.post);

		data.state.extension = {
			text: function(data) {
				data.value = data.value.replace(/^/gm, wrapTree("span", extension.text));

				return data;
			},
			header: function(header) {
				return wrapTree("span", extension.header) + header;
			},
			env: function(env) {
				return wrapTree("span", extension.text) + env;
			},
			spacer: function() {
				return wrapTree("div", extension.text);
			},
		};

		return data;
	};

	this.checker = function(config) {
		var checker = AbstractPosts.prototype.checker.apply(this, arguments);

		return compose(this.extension.bind(this, config), checker);
	};

	this.setSpacer = function(el, extension) {
		el.insertAdjacentHTML("beforeend", extension.spacer());
	};

	var headerContents = AbstractPosts.prototype.headerContents;
	var unfoldButton = AbstractPosts.prototype.unfoldButton;

	this.headerContents = function(state) {
		return state.extension.header(headerContents.apply(null, arguments));
	};

	this.unfoldButton = function(data) {
		return data.state.extension.header(unfoldButton(data));
	};

	this.doEnv = function(env, data) {
		return data.state.extension.env(env);
	};
}
ASCIIView.prototype = Object.create(AbstractPosts.prototype);

var View = {
	"tree-mode-css": CSSView,
	"tree-mode-ascii": ASCIIView,
};

function ToggleMessage(config, postParent) {
	this.config = config;
	this.postParent = postParent;
}
ToggleMessage.prototype = {
	handleEvent: function(e) {
		e.preventDefault();

		var toggleMessage = Object.create(this);
		toggleMessage.button = e.target;
		toggleMessage.message = toggleMessage.button.closest(".message");
		toggleMessage.messages = toggleMessage.message.closest(".messages");
		toggleMessage.text = toggleMessage.message.querySelector(".text");
		toggleMessage.post = toggleMessage.message.post;

		return toggleMessage.execute();
	},
	execute: function() {
		return this.setIDToPost()
			.then(this.hideOrRevert.bind(this))
			.catch(this.error.bind(this));
	},
	hideOrRevert: function() {
		if (this.isRevertButton()) {
			this.revert();
		} else {
			this.hide();
		}

		this.changeBetweenHideAndRevert();
	},
	isRevertButton: function() {
		return this.button.classList.contains("revert");
	},
	changeBetweenHideAndRevert: function() {
		this.button.classList.toggle("revert");
	},
	hide: function() {
		var post = this.post;
		post.previousRejectLevel = post.rejectLevel;
		post.rejectLevel = 3;

		this.text.style.display = 'none';
		this.button.textContent = "戻";
		this.config.addVanishedMessage(post.id);

		this.setRejectLevel(post.child, 2);
	},
	revert: function() {
		var post = this.post;
		post.rejectLevel = post.previousRejectLevel;

		this.text.style.display = null;
		this.button.textContent = "消";
		this.config.removeVanishedMessage(post.id);

		this.removeChainingHiddenMark(post.child, 2);
	},
	error: function(error) {
		this.button.parentNode.replaceChild(document.createTextNode(error.message), this.button);
	},
	setIDToPost: function() {
		return this.findPostID().then(function(id) {
			if (!id) {
				return Promise.reject(new Error(
					"最新1000件以内に存在しないため投稿番号が取得できませんでした。" +
					"過去ログからなら消せるかもしれません"
				));
			}

			if (id.length > 100) {
				return Promise.reject(new Error("この投稿は実在しないようです"));
			}

			this.post.id = id;
		}.bind(this));
	},
	findPostID: function() {
		var post = this.post;
		var id = post.id;
		if (id === undefined) {
			id = this.postParent.find(post.child.id, post.threadId);
		}

		return Promise.resolve(id);
	},
	setRejectLevel: function setRejectLevel(post, rejectLevel) {
		if (post === null || rejectLevel === 0) {
			return;
		}

		if (post.rejectLevel < rejectLevel) {
			post.rejectLevel = rejectLevel;

			var message = this.getTargetMessage(post);

			if (message && !message.querySelector(".chainingHidden")) {
				message.firstElementChild.classList.add("chainingHidden");
			}
		}

		this.setRejectLevel(post.child, rejectLevel - 1);
		this.setRejectLevel(post.next, rejectLevel);
	},
	removeChainingHiddenMark: function(post, rejectLevel) {
		if (post === null || rejectLevel === 0) {
			return;
		}

		if (post.rejectLevel <= rejectLevel) {
			post.rejectLevel = 0;

			var message = this.getTargetMessage(post);
			var mark = message.querySelector(".chainingHidden");
			if (mark) {
				mark.classList.remove("chainingHidden");
			}
		}

		this.removeChainingHiddenMark(post.child, rejectLevel - 1);
		this.removeChainingHiddenMark(post.next, rejectLevel);
	},
	getTargetMessage: function(post) {
		return this.messages.querySelector('[data-id="' + post.id + '"]');
	},
};

function Threads() {
	var el = document.createElement("div");
	el.id = "content";
	return el;
}
Threads.addEventListeners = function(config, el, postParent) {
	function click(selector, callback) {
		Threads.on(el, "click", selector, Threads.replace.bind(null, config, callback));
	}

	click(".characterEntity", function(post) {
		post.characterEntity = !(post.hasOwnProperty("characterEntity") ? post.characterEntity : config.characterEntity);
	});

	click(".showMessageButton", function(post) {
		post.show = true;
	});

	click(".cancelVanishedMessage", function(post) {
		config.removeVanishedMessage(post.id);
		delete post.rejectLevel;
	});

	click(".fold", function(post) {
		post.show = false;
	});

	Threads.on(el, "mousedown", ".message", Threads.showAsIs.bind(Threads, config));

	click(".toggleTruncation", function(post) {
		post.truncation = post.hasOwnProperty("truncation") ? !post.truncation : false;
	});

	if (config.useVanishMessage) {
		Threads.on(el, "click", ".toggleMessage", new ToggleMessage(config, postParent));
	}

	Threads.on(el, "click", ".vanish", function(e) {
		var button = e.target;
		var thread = button.closest(".thread");
		var id = thread.dataset.threadId;
		var type, text;

		if (thread.classList.contains("NGThread")) {
			type = "remove";
			text = "消";
		} else {
			type = "add";
			text = "戻";
		}
		type += "VanishedThread";

		config[type](id);
		thread.classList.toggle("NGThread");
		button.textContent = text;
	});

	Threads.on(el, "click", ".toggleTreeMode", Threads.toggleTreeMode.bind(null, config));
};
Threads.on = on;
Threads.getTreeMode = function(node) {
	return node.closest(".tree-mode-css") ? "tree-mode-css" : "tree-mode-ascii";
};
Threads.replace = function(config, change, e) {
	e.preventDefault();

	var message = e.target.closest(".message, .showMessage");
	var parent = message.parentNode;
	var post = message.post;
	var mode = Threads.getTreeMode(message);
	var view = new View[mode]();
	var maker = view.messageMaker(config);
	var depth = parseInt(message.style.marginLeft, 10);

	change(post);

	var newMessage = maker(post, depth);

	parent.insertBefore(newMessage, message);
	parent.removeChild(message);
};

Threads.toggleTreeMode = function(config, e) {
	e.preventDefault();

	var button = e.target;
	var thread = button.closest(".thread");

	thread.classList.toggle("tree-mode-css");
	thread.classList.toggle("tree-mode-ascii");

	var view = new View[Threads.getTreeMode(thread)]();
	var roots = thread.roots;
	var messages = thread.querySelector(".messages");

	view.init(roots);
	var newMessages = view.render(config);

	thread.insertBefore(newMessages, messages);
	thread.removeChild(messages);
};

Threads.showAsIs = function(config, e) {
	function callback(post) {
		post.showAsIs = !post.showAsIs;
	}

	var target = e.target;
	var id = setTimeout(Threads.replace.bind(Threads, config, callback, e), 500);
	var cancel = function() {
		clearTimeout(id);
		target.removeEventListener("mouseup", cancel);
		target.removeEventListener("mousemove", cancel);
	};

	target.addEventListener("mouseup", cancel);
	target.addEventListener("mousemove", cancel);
};

Threads.showThreads = function(config, el, threads) {
	var mode = config.treeMode;
	var view = new View[mode]();
	var utterlyVanishNGThread = config.utterlyVanishNGThread;
	var vanishedThreadIDs = config.vanishedThreadIDs;
	var threshold = +config.vanishedMessageIDs[0];
	var toggleTreeMode = mode === "tree-mode-css" && config.toggleTreeMode ? ' <a href="javascript:;" class="toggleTreeMode">●</a>' : '';
	var emptyVanishButtons = { true: "", false: "" };
	var vanishButtons = {
		true: ' <a href="javascript:;" class="vanish">戻</a>',
		false: ' <a href="javascript:;" class="vanish">消</a>',
	};

	function show(thread, pending, isVanished, roots) {
		var number = thread.getNumber();

		if (!number) {
			if (pending) {
				el.removeChild(pending);
			}

			return;
		}

		var vanish;
		if (config.useVanishThread || (isVanished && config.autovanishThread)) {
			vanish = vanishButtons;
		} else {
			vanish = emptyVanishButtons;
		}

		var url = '<a href="' + thread.getURL() + '" target="link">◆</a>';
		var html = '<pre data-thread-id="' + thread.getID() + '" class="thread ' + mode + '">' +
			'<div class="thread-header">' +
			url +
			' 更新日:' + thread.getDate() + ' 記事数:' + number +
			toggleTreeMode +
			vanish[isVanished] +
			' ' + url +
			'</div><span class="messages"></span></pre>';
		var dthread = DOM(html);

		if (isVanished) {
			dthread.classList.add("NGThread");
		}

		view.init(roots, dthread.lastChild);
		view.render(config);
		dthread.roots = roots;

		if (pending) {
			el.replaceChild(dthread, pending);
		} else {
			el.appendChild(dthread);
		}
	}

	function showThread(thread) {
		var isVanished = config.useVanishThread && vanishedThreadIDs.indexOf(thread.getID()) > -1;
		var isToBeVanished = config.autovanishThread && thread.isNG;
		var vanish = isVanished || isToBeVanished;

		if (vanish && utterlyVanishNGThread) {
			return;
		}

		var pRoots = thread.computeRoots(threshold);

		if (pRoots.then) {
			var url = '<a href="' + thread.getURL() + '" target="link">◆</a>';
			var pendingHTML = '<pre class="pending thread "' + mode + '>' +
			'<div class="thread-header">' +
			url +
			' 更新日:' + thread.getDate() +
			' ' + url +
			'</div>親子関係取得中</pre>';
			el.insertAdjacentHTML("beforeend", pendingHTML);

			return pRoots.then(show.bind(null, thread, el.lastChild, isVanished));
		} else {
			return show(thread, null, isVanished, pRoots);
		}
	}
	return loop(showThread, threads);
};

function PostParent(config, q) {
	this.useStorage = this.storageIsAvailable("localStorage");
	var tryHard = config.vanishMessageAggressive && !q.m && this.useStorage;

	if (tryHard) {
		var storage = this.sessionStorage();
		var first = !storage.getItem("qtv-session");
		if (first) {
			storage.setItem("qtv-session", true);
		}
		tryHard = tryHard && first;
	}

	this.config = config;
	this.tryHard = tryHard;
}

PostParent.prototype = {
	useStorage: false,
	nullStorage: function() {
		return {
			getItem: function() { return null; },
			setItem: doNothing,
		};
	},
	sessionStorage: function() {
		return sessionStorage;
	},
	getStorage: function() {
		if (this.useStorage) {
			return this.config.useVanishMessage ? localStorage : sessionStorage;
		} else {
			return this.nullStorage();
		}
	},
	load: function() {
		this.data = JSON.parse(this.getStorage().getItem("postParent")) || {};
	},
	save: function(data) {
		this.getStorage().setItem("postParent", JSON.stringify(data));
	},
	saveAsync: function(data) {
		setTimeout(this.save.bind(this), 0, data);
	},
	setWhenToCleanUp: function(view) {
		view.then(function() {
			setTimeout(this.cleanUp.bind(this), 10 * 1000);
		}.bind(this));
	},
	update: function(posts) {
		if (!posts.length) {
			return;
		}
		var changed = false;

		this.load();

		var data = this.data;

		for (var i = 0, len = posts.length; i < len; i++) {
			var post = posts[i];
			var id = post.id;
			var parentID = post.parentId;

			if (data.hasOwnProperty(id)) {
				continue;
			}

			if (parentID && parentID.length > 20) {
				parentID = null;
			}

			data[id] = parentID;
			changed = true;
		}
		if (changed) {
			this.saveAsync(data);
		}
	},

	limit: function() {
		if (this.config.useVanishMessage) {
			if (this.config.vanishMessageAggressive) {
				return { upper: 3500, lower: 3300 };
			} else {
				return { upper: 1500, lower: 1300 };
			}
		} else {
			return { upper: 500, lower: 300 };
		}
	},
	cleanUp: function() {
		if (!this.data) {
			return;
		}
		var ids = Object.keys(this.data);
		var length = ids.length;
		var limit = this.limit();
		if (length > limit.upper) {
			ids = ids.map(function(id) {
				return +id;
			}).sort(function(l, r) {
				return r - l;
			});

			if (this.data[ids[0]] === false) {
				ids.shift();
			}

			var saveData = {};
			var i = limit.lower;
			while (i--) {
				saveData[ids[i]] = this.data[ids[i]];
			}
			this.saveAsync(saveData);
		}
	},
	isNumber: function(number) {
		return /^(?!0)\d+$/.test(number);
	},
	updateThread: function(threadID) {
		return DOM.fetch({data: { m: 't', s: threadID}})
			.then(Post.makePosts)
			.then(this.update.bind(this))
			.then(function() {
				return this.data;
			}.bind(this));
	},
	head: function(array) {
		return array[0];
	},
	find: function(childID, opt_threadID) {
		if (!this.isNumber(childID)) {
			throw new TypeError('"' + childID + '"は自然数の文字列');
		}

		if (opt_threadID && typeof this.data[childID] === "undefined") {
			return this.findAll([childID], opt_threadID, true)
				.then(this.head);
		}

		return this.data[childID];
	},

	notContainedIn: function(id) {
		return typeof this[id] === "undefined";
	},
	needsToFetch: function(childIDs, threadID, force) {
		return (this.tryHard || force) &&
			this.useStorage &&
			this.isNumber(threadID) && // 要らないかもしれない
			childIDs.some(this.notContainedIn, this.data);
	},
	from: function(p) { return this[p]; },
	collect: function(ids, id) {
		ids[id] = this.data[id];

		return ids;
	},
	findAll: function(childIDs, threadID, opt_force) {
		if (!this.needsToFetch(childIDs, threadID, opt_force)) {
			return childIDs.reduce(this.collect.bind(this), Object.create(null));
		}

		if (!this.updateThreadMemoized) {
			this.updateThreadMemoized = memoize(this.updateThread.bind(this));
		}

		return this.updateThreadMemoized(threadID)
			.then(childIDs.map.bind(childIDs, this.from));
	},
	storageIsAvailable: function(type, win) {
		win = win || window;
		// https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API/Using_the_Web_Storage_API#Feature-detecting_localStorage
		try {
			var storage = win[type],
				x = '__storage_test__';
			storage.setItem(x, x);
			storage.removeItem(x);
			return true;
		}
		catch (e) {
			return false;
		}
	},
};

function Preload(head) {
	this.preloads = Object.create(null);
	this.head = head || document.head;

	var DOMTokenListSupports = function(tokenList, token) {
		if (!tokenList || !tokenList.supports) {
			return;
		}
		try {
			return tokenList.supports(token);
		} catch (e) {
			if (e instanceof TypeError) {
				console.log("The DOMTokenList doesn't have a supported tokens list");
			} else {
				console.error("That shouldn't have happened");
			}
		}
	};
	this.isSupported = DOMTokenListSupports(document.createElement("link").relList, "preload");

}
Preload.prototype.fetch = function(url) {
	if (!this.isSupported || this.isFetched(url)) {
		return;
	}

	var link = document.createElement("link");
	link.rel = "preload";
	link.as = "image";
	link.href = url;

	this.head.appendChild(link);
	this.preloads[url] = true;
};
Preload.prototype.isFetched = function(url) {
	return this.preloads[url];
};

function Thumbnail(config) {
	this.config = config;
	this.preload = new Preload();

	var animationChecker = memoize(Thumbnail.checkAnimation);

	// ポップアップを消した時、カーソルがサムネイルの上にある
	this.isClosedAboveThumbnail = function(e) {
		var relatedTarget = e.relatedTarget;

		//firefox:
		if (relatedTarget === null) {
			return true;
		}

		//opera12
		if (relatedTarget instanceof HTMLBodyElement) {
			return true;
		}

		//chrome
		if (relatedTarget.closest("#image-view") && !document.getElementById("image-view")) {
			return true;
		}
	};

	function setNote(a, text) {
		var note = a.nextElementSibling;
		// span.noteがない
		if (!note || !note.classList.contains("note")) {
			note = document.createElement("span");
			note.className = "note";

			a.parentNode.insertBefore(note, a.nextSibling);
		}

		note.textContent = text;
	}

	this.downloading = function(image, a) {
		var pending = true;
		var complete = function(success) {
			pending = false;
			if (success) {
				var note = a.nextElementSibling;
				if (note && note.classList.contains("note")) {
					note.parentNode.removeChild(note);
				}
			} else {
				setNote(a, "404?画像ではない?");
			}
		};

		image.addEventListener("load", complete.bind(null, true));
		image.addEventListener("error", complete.bind(null, false));


		setTimeout(function() {
			if (pending) {
				setNote(a, "ダウンロード中");
			}
		}, 100);
	};

	this.handleEvent = function(e) {
		if (this.isClosedAboveThumbnail(e)) {
			return;
		}

		var a = e.currentTarget;

		// ポップアップからサムネイルに帰ってきた
		if (a.classList.contains("popup")) {
			return;
		}

		var image = new Image();
		image.referrerPolicy = "no-referrer";

		this.downloading(image, a);

		image.classList.add("image-view-img");
		image.src = a.href;

		a.classList.add("popup");

		var popup = new Popup(config, image);
		popup.addEventListeners();
		popup.waitAndOpen();
	};

	var misaoSmall = function(href) {
		var src = href;

		if (!/^http:\/\/misao\.(?:mixh|on\.arena\.ne)\.jp\/c\/up\/misao\d+\.\w+$/.test(href)) {
			return src;
		}

		return src.replace(/up\//, "up/pixy_");
	};
	var misaoAnimation = function(href) {
		if (!config.linkAnimation) {
			return;
		}

		var misao = /^(http:\/\/misao\.(?:mixh|on\.arena\.ne)\.jp\/c\/)up\/(misao0*\d+)\.(?:png|jpg)$/.exec(href);

		if (misao) {
			var misaoID = misao[2];
			var animationURL = misao[1] + 'upload.cgi?m=A&id=' + (/(?!0)\d+/).exec(misaoID)[0];

			animationChecker(href).then(function(isAnimation) {
				setTimeout(function() {
					if (!document.body) {
						throw new Error("no body");
					}
					var animations = document.getElementsByClassName(misaoID);

					Array.prototype.slice.call(animations).forEach(function(animation) {
						if (isAnimation) {
							var unsure = animation.getElementsByClassName("unsure")[0];
							if (unsure) {
								animation.removeChild(unsure);
							}
						} else {
							animation.parentNode.removeChild(animation);
						}
					});
				});
			});

			return {id: misaoID, href: animationURL};
		}
	};

	this.image = {
		sw: [{
			name: "misao",
			prefix: "http://misao.mixh.jp/c/",
			urls: function(href) {
				return {
					original: href,
					small: this.small(href),
					animation: this.animation(href),
				};
			},
			small: misaoSmall,
			animation: misaoAnimation,
		}, {
			name: "misao-arena",
			prefix: "http://misao.on.arena.ne.jp/c/",
			urls: function(href) {
				return {
					original: href,
					small: this.small(href),
					animation: this.animation(href),
				};
			},
			small: misaoSmall,
			animation: misaoAnimation,
		}, {
			name: "betanya",
			prefix: "http://komachi.betanya.com/uploader/stored/",
			urls: function(href) {
				return {
					original: href,
					small: href,
				};
			},
		}],

		otherSites: [{
			name: "imgur",
			prefix: /^https?:\/\/(?:i\.)?imgur\.com\/[^/]+$/,
			urls: function(href) {
				var original = href.replace(/^https?:\/\/(?:i\.)?/, "https:/i.");
				var thumbnail = original.replace(/\.\w+$/, "t$&");

				return {
					original: original,
					small: thumbnail,
				};
			},
		}, {
			name: "twimg",
			prefix: /^https?:\/\/pbs\.twimg\.com\/media\/[\w_-]+\.\w+/,
			suffix: /(?::(?:orig|large|medium|small|thumb))?$/,
			urls: function(href) {
				var parts = this.prefix.exec(href);
				if (!parts) {
					return;
				}

				href = parts[0];

				return {
					original: href + ":orig",
					small: href + ":thumb",
				};
			},
		}, {
			name: "any",
			suffix: /^[^?#]+\.(?:jpe?g|png|gif|bmp)(?:[?#]|$)/i,
			urls: function(href) {
				return {
					original: href,
				};
			},
		}],
	};

	this.thumbnailLink = function(href) {
		var thumbnail;

		if (/\.(?:jpe?g|png|gif|bmp)$/i.test(href)) {
			thumbnail = this.loopSites(this.image.sw, href, startsWith, null);
		}

		if (!thumbnail && config.popupAny) {
			thumbnail = this.loopSites(this.image.otherSites, href, test, test);
		}

		return thumbnail;
	};

	this.loopSites = function(sites, href, testPrefix, testSuffix) {
		for (var i = 0; i < sites.length; ++i) {
			var thumbnail = this.thumbnailThis(sites[i], href, testPrefix, testSuffix);

			if (thumbnail) {
				return thumbnail;
			}
		}
	};

	this.thumbnailThis = function(site, href, testPrefix, testSuffix) {
		var suffix = site.suffix;
		var prefix = site.prefix;

		if (testSuffix && testSuffix(href, suffix)) {
			return;
		}

		if (testPrefix && testPrefix(href, prefix)) {
			return;
		}

		return this.construct(site.urls(href));
	};

	function startsWith(href, string) {
		return string && !href.startsWith(string);
	}

	function test(href, test) {
		return test && !test.test(href);
	}

	this.small = function(original, small) {
		// if (!original) {
		// 	throw new Error();
		// }
		if (!small) {
			return small;
		}

		if (original === small) {
			return small;
		}

		if (!config.thumbnailPopup) {
			return small;
		}

		this.preload.fetch(original);

		if (this.preload.isFetched(original)) {
			return small;
		} else {
			return original;
		}
	};

	this.a = function(original) {
		return '<a href="' + original + '" target="link" class="thumbnail">';
	};

	this.thumbnail = function(original, small) {
		var a = this.a(original);

		if (small) {
			return a + '<img referrerpolicy="no-referrer" class="thumbnail-img" src="' + small + '"></a>';
		} else {
			return '[' + a + '■</a>]';
		}
	};

	this.construct = function(data) {
		var original = data.original;
		var small = this.small(original, data.small);

		var thumbnail = this.thumbnail(original, small);

		var animation = data.animation;
		if (animation) {
			thumbnail += '<span class="animation ' + animation.id + '">[<a href="' + animation.href + '" target="link">A</a><span class="unsure">?</span>]</span>';
		}

		if (config.shouki) {
			thumbnail += shouki(original);
		}

		return thumbnail;
	};

	function shouki(href) {
		return '[<a href="http://images.google.com/searchbyimage?image_url=' + href + '" target="link">詳</a>]';
	}

	this.register = function(container) {
		var as = container.querySelectorAll('a[target]');
		var has = false;
		var i;
		for (i = as.length - 1; i >= 0; i--) {
			var a = as[i];
			var href = a.href;
			var thumbnail = this.thumbnailLink(href);
			if (thumbnail) {
				a.insertAdjacentHTML('beforebegin', thumbnail);
				has = true;
			}
		}
		if (has && config.thumbnailPopup) {
			var thumbs = container.getElementsByClassName('thumbnail');
			for (i = thumbs.length - 1; i >= 0; i--) {
				thumbs[i].addEventListener("mouseover", this, false);
			}
		}
	};
}

Thumbnail.checkAnimation = function(imgURL) {
	return new Promise(function(resolve) {
		var url = imgURL.replace(/\w+$/, "pch");
		if (typeof GM_xmlhttpRequest === "function") {
			GM_xmlhttpRequest({
				url: url,
				method: "HEAD",
				onload: function(response) {
					resolve(response.status === 200);
				},
			});
		} else if (Env.IS_EXTENSION) {
			ajax({
				url: url,
				type: "HEAD",
			}).then(function() {
				resolve(true);
			}, function() {
				resolve(false);
			});
		}
	});
};

function Popup(config, image, body) {
	body = body || document.body;
	this.waitingMetadata = null;

	this.handleEvent = function(e) {
		var type = e.type;

		if (type === "keydown" && !/^Esc(?:ape)?$/.test(e.key) && e.keyIdentifier !== "U+001B") { // ESC
			return;
		}
		if (type === "mouseout" && e.relatedTarget.closest(".popup")) {
			return;
		}

		this.doHandleEvent();
	};

	this.doHandleEvent = function() {
		var popup = document.getElementById("image-view");
		if (popup) {
			popup.parentNode.removeChild(popup);
		}

		Array.prototype.slice.call(document.getElementsByClassName("popup")).forEach(function(el) {
			el.classList.remove("popup");
		});

		this.removeEventListeners(body);

		if (this.waitingMetadata) {
			clearTimeout(this.waitingMetadata);
		}
	};

	this.addEventListeners = function() {
		this.toggleEventListeners("add");
	};
	this.removeEventListeners = function() {
		this.toggleEventListeners("remove");
	};
	this.toggleEventListeners = function(toggle) {
		["click", "keydown", "mouseout"].forEach(function(type) {
			body[toggle + "EventListener"](type, this);
		}, this);
	};

	function getRatio(natural, max) {
		if (/^\d+$/.test(max) && natural > max) {
			return +max / natural;
		} else {
			return 1;
		}
	}

	this.popup = function() {
		var isBestFit = config.popupBestFit;
		var viewport = document.compatMode === "BackCompat" ? document.body : document.documentElement;
		var windowHeight = viewport.clientHeight;
		var windowWidth = viewport.clientWidth;
		var imageView = document.createElement("figure");
		imageView.id = "image-view";
		imageView.classList.add("popup");
		imageView.style.visibility = "hidden";
		imageView.innerHTML = '<figcaption><span id="percentage"></span>%</figcaption>';

		// bodyに追加することでimage-orientationが適用され
		// natural(Width|Height)以外の.*{[wW]idth|[hH]eight)が
		// EXIFのorientationが適用された値になる
		imageView.appendChild(image);
		body.appendChild(imageView);

		var width = image.offsetWidth;
		var height = image.offsetHeight;
		var marginHeight = Math.round(imageView.getBoundingClientRect().height) - height;
		var maxWidth = config.popupMaxWidth || (isBestFit ? windowWidth : width);
		var maxHeight = config.popupMaxHeight || (isBestFit ? windowHeight - marginHeight : height);
		var ratio = Math.min(getRatio(width, maxWidth), getRatio(height, maxHeight));
		var percentage = Math.floor(ratio * 100);
		var bgcolor = ratio < 0.5 ? "red" : ratio < 0.9 ? "blue" : "green";
		// 丸めないと画像が表示されないことがある
		var imageHeight = Math.floor(height * ratio) || 1;
		var imageWidth = Math.floor(width * ratio) || 1;

		imageView.style.display = "none";
		image.height = imageHeight;
		image.width = imageWidth;

		imageView.querySelector("#percentage").textContent = percentage;

		imageView.style.cssText = 'background-color: ' + bgcolor;
	};

	this.waitAndOpen = function() {
		if (!image.complete && image.naturalWidth === 0 && image.naturalHeight === 0) {
			this.waitingMetadata = setTimeout(this.waitAndOpen.bind(this), 50);
		} else {
			this.waitingMetadata = null;
			this.popup();
		}
	};
}

function Fetch(q, now) {
	this.now = now || Date.now();

	var chk = this.getChk(q);

	if (chk) {
		this.today = chk.match(/\d+/)[0];
		this.hasOP = function() { return true; };

		this.data = function(ff) {
			var data = Object.assign({}, q);
			delete data[chk];
			data["chk" + ff] = "checked";

			return data;
		};
	} else {
		this.data = function(ff) {
			return {
				__proto__: q,
				ff: ff,
			};
		};
		this.today = +q.ff.match(/^(\d{8})\.dat$/)[1];
		var query = 'a[name="' + q.s + '"]';
		this.hasOP = function(container) {
			return container.querySelector(query);
		};
	}

	this.thisLog = this.today + ".dat";
}
Fetch.prototype.getChk = function(q) {
	return Object.keys(q).find(function(key) {
		return /^chk\d+\.dat$/.test(key);
	});
};
Fetch.prototype.dates = function() {
	var ONE_DAY = 24 * 60 * 60 * 1000;
	var afters = [];
	var befores = [];
	var fill = function(n) {
		return n < 10 ? "0" + n : n;
	};

	for (var i = 0; i < 7; i++) {
		var back = new Date(this.now - ONE_DAY * i);
		var year = back.getFullYear();
		var month = fill(back.getMonth() + 1);
		var date = fill(back.getDate());
		var day = "" + year + month + date;
		if (day > this.today) {
			afters.push(day);
		} else if (day < this.today) {
			befores.push(day);
		}
	}

	return {afters: afters, befores: befores};
};
Fetch.prototype.both = function(container) {
	var dates = this.dates();

	var after = this.concurrent(dates.afters);
	var before = this.sequence(dates.befores, container);

	return Promise.all([after, before]).then(function(args) {
		return {afters: args[0], befores: args[1]};
	});
};
Fetch.prototype.after = function() {
	var dates = this.dates();
	var after = this.concurrent(dates.afters);

	return after.then(function(afters) {
		return {afters: afters, befores: []};
	});
};
Fetch.prototype.fetch = function(date) {
	var ff = date + ".dat";

	return DOM.fetch({url: "bbs.cgi", data: this.data(ff)})
	.then(function(div) {
		div.ff = ff;
		return div;
	});
};
Fetch.prototype.sequence = function(dates, container) {
	var divs = [];
	var fetch = this.fetch.bind(this);
	var hasOP = this.hasOP;
	var sequence = dates.reduce(function(sequence, date) {
		return sequence.then(function(done) {
			if (done) {
				return done;
			}

			return fetch(date)
			.then(function(div) {
				divs.push(div);

				return hasOP(div);
			});
		});
	}, Promise.resolve(hasOP(container)));

	return sequence.then(function() {
		return divs;
	});
};
Fetch.prototype.concurrent = function(dates) {
	return Promise.all(dates.map(this.fetch.bind(this)));
};

function doNothing() {}

function ready(readyState) {
	return new Promise(function(resolve) {
		readyState = readyState || document.readyState;
		if (/complete|loaded|interactive/.test(readyState) && document.body) {
			resolve(document.body);
		} else {
			document.addEventListener('DOMContentLoaded', function(e) {
				resolve(e.target.body);
			}, {once: true});
		}
	});
}

var ResWindow = {
	ready: function(readyState) {
		return ready(readyState).then(this.tweak);
	},
	tweak: function(body) {
		var v = body.querySelector("textarea");
		if (v) {
			v.focus(); // Firefox needs focus before setSelectionRange.
			v.scrollIntoView();
			// 内容を下までスクロール firefox, opera12
			v.setSelectionRange(v.textLength, v.textLength);
			// 内容を下までスクロール chrome
			v.scrollTop = v.scrollHeight;
		}
	},
};

/*global ConfigController*/
var eventHandlers = {
	openConfig: function(config, body, e, chrome_) {
		e.preventDefault();

		chrome_ = chrome_ || (typeof chrome === "object" ? chrome : undefined);

		if (chrome_ && chrome_.runtime.id) {
			chrome_.runtime.sendMessage({type: "openConfig"});
		} else if (!document.getElementById("config")) {
			body.insertBefore(new ConfigController(config).el, body.firstChild);
			window.scrollTo(0, 0);
		}
	},
	tweakLink: function(config, e) {
		var a = e.target;

		if (config.openLinkInNewTab && a.target === "link") {
			a.target = "_blank";
		}

		if (a.target) {
			a.rel += " noreferrer noopener";
		}
	},
	reload: function(e, loc) {
		loc = loc || location;

		var form = document.getElementById("form");
		if (!form) {
			loc.reload();
			return;
		}

		var reload = document.getElementById("qtv-reload");
		if (!reload) {
			reload = DOM('<input type="submit" id="qtv-reload" name="reload" value="1" style="display:none;">');
			document.forms[0].appendChild(reload);
		}

		reload.click();
	},
	midokureload: function(e, loc) {
		loc = loc || location;

		if (document.getElementById("form")) {
			document.getElementsByName("midokureload")[0].click();
		} else {
			loc.reload();
		}
	},
	clearVanishedIDs: function(config, method, e) {
		e.preventDefault();
		config[method]();
		e.target.firstElementChild.innerHTML = "0";
	},
};

var AppCommon = {
	execute: function(config, body) {
		this.injectCSS(config);
		this.zero(config);
		this.addCommonEvents(config, body);
		this.setAccesskeyToV(config);
		this.registerKeyboardNavigation(config);
		this.setID();
	},
	registerKeyboardNavigation: function(config, doc_) {
		if (config.keyboardNavigation) {
			this.keyboardNavigation = this.createKeyboardNavigation(config);
			(doc_ || document).addEventListener("keypress", this.keyboardNavigation, false);
		}
	},
	setReloadable: function() {
		if (this.keyboardNavigation) {
			this.keyboardNavigation.setReloadable();
		}
	},
	createKeyboardNavigation: function(config) {
		return new KeyboardNavigation(config, window);
	},
	zero: function(config) {
		if (config.zero) {
			var d = document.getElementsByName("d")[0];
			if (d && d.value !== "0") {
				d.value = "0";
			}
		}
	},
	addCommonEvents: function(config, body) {
		on(body, "click", "#openConfig", eventHandlers.openConfig.bind(eventHandlers, config, body));
		on(body, "click", "a", eventHandlers.tweakLink.bind(null, config));
	},
	setAccesskeyToV: function(config) {
		var accessKey = config.accesskeyV;
		if (accessKey.length === 1) {
			var v = document.getElementsByName("v")[0];
			if (v) {
				v.accessKey = accessKey;
			}
		}
	},
	setID: function() {
		var forms = document.forms;
		if (forms.length) {
			var form = forms[0];
			form.id = "form";
			var fonts = form.getElementsByTagName("font");
			if (fonts.length >= 3) {
				fonts[fonts.length - 3].id = "link";
			}
		}
	},
	// injectCSSは下の方で定義
};

var ErrorHandler = {
	handle: function(e, body) {
		if (ErrorHandler.error) {
			throw e;
		}

		ErrorHandler.error = e;
		body = body || ErrorHandler.getBody();
		var lineNumber = e.lineNumber || 0;

		var pre = document.createElement("pre");
		pre.innerHTML = 'くわツリービューの処理を中断しました。表示されていない投稿があります。<a href="javascript:;">スタックトレースを表示する</a>';

		var dStackTrace = document.createElement("p");
		dStackTrace.style.display = "none";

		var stackTrace = "";
		if (typeof GM_info !== "undefined") {
			stackTrace += GM_info.version + "+" + GM_info.script.version + "\n";
		}
		var stack = (e.stackTrace || e.stack || "");
		stackTrace += e.name + ": " + e.stackTrace + ":" + lineNumber + "\n" + stack;

		dStackTrace.textContent = stackTrace;

		pre.appendChild(dStackTrace);
		pre.addEventListener("click", ErrorHandler.showStackTrace);

		body.insertBefore(pre, body.firstChild);

		throw e;
	},
	getBody: function() { return document.body; },
	showStackTrace: function(e) {
		e.target.parentNode.querySelector("p").style.display = null;
	},
};

var App = {
	execute: function(config, q, container, execute) {
		if (App.checkResWindow(document)) {
			if (config.closeResWindow) {
				App.closeResWindow();
			}
		} else if (App.checkSetupWindow(document)) {
			// Do nothing
		} else {
			var app = Object.create(AppCommon);
			app.execute(config, container);

			var done = execute(config, q, container);

			return Promise.resolve(done).then(function() {
				app.setReloadable();
			});
		}
	},
	checkResWindow: function(document) {
		return document.title.endsWith(" 書き込み完了");
	},
	checkSetupWindow: function(document) {
		return document.title.endsWith(" 個人用環境設定");
	},
	closeResWindow: function() {
		if (Env.IS_EXTENSION) {
			chrome.runtime.sendMessage({type: "closeTab"});
		} else {
			window.open("", "_parent");
			window.close();
		}
	},
};

var AppGM = {
	main: function(config, q) {
		return AppGM.doMain(config, q).catch(ErrorHandler.handle);
	},
	doMain: function(config, q) {
		return Promise.all([config, ready()]).then(function(args) {
			var config = args[0];
			var body = args[1];
			return App.execute(config, q, body, AppGM.view(config));
		});
	},
	view: function(config) {
		return config.isTreeView() ? tree : stack;
	},
};

/* global AppChrome */
/* exported whatToDo */
function whatToDo(q, hostname) {
	switch (q.m) {
		case "f": //レス窓
			return ResWindow.ready.bind(ResWindow);
		case "l": //トピック一覧
		case "c": //個人用設定
			return doNothing;
		case 'g': //過去ログ
			if (!q.sv && !(q.e && /^misao\.(mixh|on\.arena\.ne)\.jp$/.test(hostname))) {
				return doNothing;
			}
	}

	return window.MutationObserver ? AppChrome.main : AppGM.main;
}

function TreeGUI(config, body) {
	this.config = config;
	this.body = body;
}
TreeGUI.prototype = {
	template: function() {
		var reload = this.createReload();
		var accesskey = this.getAccesskey();
		var viewsAndViewing = this.getViewsAndViewing();

		var vanishedThreadIDLength = this.config.vanishedThreadIDs.length;
		var vanishedMessageIDLength = this.config.vanishedMessageIDs.length;
		var hidden = vanishedThreadIDLength || vanishedMessageIDLength ? "" : " hidden";

		var containee =
			'<header id="header">' +
				'<span class="left">' +
					reload.replace('class="mattari"', '$& accesskey="' + accesskey + '"') + ' ' +
					viewsAndViewing +
					'<span id="postcount"></span>' +
				'</span>' +
				'<span>' +
					'<a href="javascript:;" id="openConfig">設定</a> ' +
					'<a href="#link">link</a> ' +
					'<a href="#form" class="goToForm">投稿フォーム</a> ' +
					reload +
				'</span>' +
			'</header>' +
			'<hr>' +
			'<footer id="footer">' +
				'<span class="left">' +
					reload +
				'</span>' +
				'<span>' +
					'<span class="clearVanishedButtons' + hidden + '">' +
						'非表示解除(' +
							'<a id="clearVanishedThreadIDs" href="javascript:;"><span class="count">' + vanishedThreadIDLength + '</span>スレッド</a>/' +
							'<a id="clearVanishedMessageIDs" href="javascript:;"><span class="count">' + vanishedMessageIDLength + '</span>投稿</a>' +
						')' +
					'</span> ' +
					reload +
				'</span>' +
			'</footer>';
		return containee;
	},
	createReload: function() {
		var reload = '<input type="button" value="リロード" class="mattari">';

		if (!this.config.zero) {
			reload = reload.replace('mattari', 'reload');
			reload += '<input type="button" value="未読" class="mattari">';
		}

		return reload;
	},
	getAccesskey: function() {
		var accesskey = this.config.accesskeyReload;
		return /^\w$/.test(accesskey) ? accesskey : "R";
	},
	getViewsAndViewing: function() {
		var hr = this.body.getElementsByTagName("hr")[0];
		if (hr) {
			var font = hr.previousElementSibling;
			if (font && font.tagName === "FONT") {
				var tmp = font.textContent.match(/\d+/g) || [];
				var views = tmp[3];
				var viewing = tmp[5];
				return views + ' / ' + viewing + '名 ';
			}
		}

		return "";
	},
	render: function() {
		var el = document.createElement("div");
		el.id = "container";
		var click = on.bind(null, el, "click");

		//event
		click(".reload", eventHandlers.reload);
		click(".mattari", eventHandlers.midokureload);
		click('.goToForm', this.focusV);
		['Message', 'Thread'].forEach(function(type) {
			var id = 'clearVanished' + type + 'IDs';
			click('#' + id, eventHandlers.clearVanishedIDs.bind(null, this.config, id));
		}.bind(this));

		el.innerHTML = this.template();

		var header = el.firstChild;
		var firstChildOfHeader = header.firstChild;
		var postcount = firstChildOfHeader.lastChild;

		var info = new Info();
		info.textContent = "ダウンロード中...";
		firstChildOfHeader.appendChild(info);

		var threads = new Threads();
		el.insertBefore(threads, header.nextSibling);

		this.gui = {
			container: el,
			info: info,
			content: threads,
			postcount: postcount,
			footer: el.lastChild,
		};

		return this;
	},
	prependToBody: function() {
		this.body.insertBefore(this.gui.container, this.body.firstChild);
	},
	focusV: function() {
		setTimeout(function() {
			document.getElementsByName("v")[0].focus();
		}, 50);
	},
	setInfo: function(text) {
		this.gui.info.textContent = text;
	},
	setInfoHTML: function(html) {
		this.gui.info.innerHTML = html;
	},
	clearInfo: function() {
		this.gui.info.textContent = "";
	},
	appendExtraInfoHTML: function(html) {
		this.gui.info.insertAdjacentHTML("afterend", html);
	},
	setPostCount: function(message) {
		this.gui.postcount.textContent = message;
	},
	showSaving: function() {
		this.buttons = this.gui.footer.querySelector(".clearVanishedButtons");
		this.buttons.insertAdjacentHTML("beforebegin", '<span class="savingVanishedThreadIDs">非表示スレッド保存中</span>');
	},
	showSaved: function() {
		var buttons = this.buttons;
		var saving = buttons.previousElementSibling;
		saving.parentNode.removeChild(saving);

		var threadLength = this.config.vanishedThreadIDs.length;

		if (threadLength) {
			buttons.querySelector("#clearVanishedThreadIDs .count").textContent = threadLength;
			buttons.classList.remove("hidden");
		}
	},
};

var Tree = {
	execute: function(config, q, gui, container) {
		var posts = this.makePosts(config, container);

		this.tweakFooter(container, posts.length);

		this.tweakURL(q, posts);

		var mPosts = this.addExtraLog(config, q, gui, container, posts);
		return mPosts.then(this.show.bind(this, config, q, gui))
			.then(function() {
				return mPosts;
			});
	},
	makePosts: function(config, container) {
		var posts = Post.makePosts(container);
		return this.processNG(config, posts);
	},
	processNG: function(config, posts) {
		if (!config.ng.isEnabled) {
			return posts;
		}

		this.checkNG(config.ng, posts);

		if (!config.autovanishThread && config.utterlyVanishNGStack) {
			return this.excludeNG(posts);
		}

		return posts;
	},
	addExtraLog: function(config, q, gui, container, posts) {
		var target;
		if (this.needsToSearchLog(q)) {
			target = "both";
		} else if (this.isFromKomachi(document.referrer, this.href())) {
			target = "after";
		} else {
			return Promise.resolve(posts);
		}

		return this.fetchFromRemote(config, gui, new Fetch(q), target, container, posts);
	},
	//通常モードからスレッドボタンを押した場合
	isThreadSearchWithin1000: function(q) {
		return q.m === 't' && !q.ff && /^\d+$/.test(q.s);
	},
	//検索窓→投稿者検索→★の結果の場合
	isPosterSearchInLog: function(q) {
		return q.s && q.ff && q.m === 's';
	},
	needsToTweakLink: function(q) {
		return this.isThreadSearchWithin1000(q) || this.isPosterSearchInLog(q);
	},
	needsToSearchLog: function(q) {
		return q.m === "t" && /^\d+\.dat$/.test(q.ff) && /^\d+$/.test(q.s);
	},
	isFromKomachi: function(referrer, href) {
		return /^http:\/\/misao\.(?:mixh|on\.arena\.ne)\.jp\/c\/upload\.cgi/.test(referrer) &&
		/^http:\/\/misao\.(?:mixh|on\.arena\.ne)\.jp\/cgi-bin\/bbs\.cgi\?chk\d+\.dat=checked&kwd=http:\/\/misao\.(?:mixh|on\.arena\.ne)\.jp\/c\/up\/misao\d+\.\w+&s1=0&e1=0&s2=24&e2=0&ao=a&tt=a&alp=checked&btn=checked(?:&g=checked)?&m=g&k=%82%A0&sv=on$/.test(href);
	},
	checkNG: function(ng, posts) {
		for (var i = 0; i < posts.length; ++i) {
			Post.checkNG(ng, posts[i]);
		}
	},
	excludeNG: function(posts) {
		return posts.filter(function(post) {
			return !post.isNG;
		});
	},
	show: function(config, q, gui, posts) {
		var postParent = Tree.makePostParent(config, q);

		gui.setInfo(" - スレッド構築中");
		Threads.addEventListeners(config, gui.gui.content, postParent);

		Tree.suggestLinkToLog(q, Tree.href(), gui, posts);
		Tree.setPostCount(gui, posts.length);

		postParent.update(posts);

		var threads = Tree.threads(config, postParent, posts);
		Tree.sortThreads(config, threads);

		this.autovanishThread(config, gui, threads);

		var done = Threads.showThreads(config, gui.gui.content, threads);
		done.then(gui.clearInfo.bind(gui));

		postParent.setWhenToCleanUp(done);

		return done;
	},
	autovanishThread: function(config, gui, threads) {
		if (!config.autovanishThread) {
			return;
		}

		var ids = threads.filter(function(thread) {
			return thread.isNG;
		}).map(function(thread) {
			return thread.id;
		});

		if (!ids.length) {
			return;
		}

		gui.showSaving();

		return config.addVanishedThread(ids).then(gui.showSaved.bind(gui));
	},
	makePostParent: function(config, q) {
		return new PostParent(config, q);
	},
	href: function() {
		return location.href;
	},
	deleteOriginal: function(config, body) {
		if (config.deleteOriginal) {
			Tree.originalRange(body).deleteContents();
		}
	},
	originalRange: function(container) {
		function startNode(container, firstAnchor) {
			var h1 = container.querySelector("h1");
			if (h1 && h1.compareDocumentPosition(firstAnchor) & Node.DOCUMENT_POSITION_FOLLOWING) {
				return h1;
			} else {
				return firstAnchor;
			}
		}

		var range = document.createRange();

		var firstAnchor = container.querySelector("a[name]");
		if (!firstAnchor) {
			return range;
		}

		var end = Tree.kuzuhaEnd(container);
		if (!end) {
			return range;
		}

		var start = startNode(container, firstAnchor);

		range.setStartBefore(start);
		range.setEndAfter(end);

		return range;
	},
	kuzuhaEnd: function(container) {
		var last = container.lastChild;
		while (last) {
			var type = last.nodeType;
			if (
				(type === Node.COMMENT_NODE && last.nodeValue === ' ') ||
				(type === Node.ELEMENT_NODE && last.nodeName === "H3")
			) {
				return last;
			}

			last = last.previousSibling;
		}

		return null;
	},
	tweakURL: function(q, posts) {
		if (!this.needsToTweakLink(q)) {
			return;
		}

		posts.forEach(function(post) {
			var date = post.date.match(/\d+/g);
			var ff = '&ff=' + date[0] + date[1] + date[2] + '.dat';
			post.threadUrl += ff; //post.threadUrl.replace(/&ac=1$/, "")必要?
			if (post.resUrl) {
				post.resUrl += ff;
			}
			if (post.posterUrl) {
				post.posterUrl += ff;
			}
		});
	},
	fetchFromRemote: function(config, gui, fetcher, target, container, posts) {
		gui.setInfoHTML('<strong>' + fetcher.thisLog + "以外の過去ログを検索中...</strong>");

		var makeArray = function(posts, div) {
			var newPosts = this.makePosts(config, div);
			return posts.concat(newPosts);
		}.bind(this);

		return fetcher[target](container).then(function(doms) {
			return [].concat(
				doms.afters.reduce(makeArray, []),
				posts,
				doms.befores.reduce(makeArray, [])
			);
		});
	},
	threads: function(config, postParent, posts) {
		var allThreads = Object.create(null);
		var threads = [];

		posts.forEach(function(post) {
			var id = post.threadId;
			var thread = allThreads[id];
			if (!thread) {
				thread = allThreads[id] = new Thread(config, postParent, id);
				threads.push(thread);
			}

			thread.addPost(post);
		});

		return threads;
	},
	sortThreads: function(config, threads) {
		if (config.threadOrder === "ascending") {
			threads.reverse();
		}
	},
	whenToSuggestLinkToLog: function(q, posts) {
		return q.m === 't' && !q.ff && /^\d+$/.test(q.s) && posts.every(function(post) {
			return !post.isOP();
		});
	},
	suggestLinkToLog: function(q, href, gui, posts) {
		if (!posts) {
			throw new Error("no posts");
		}

		if (Tree.whenToSuggestLinkToLog(q, posts)) {
			var fill = function(n) {
				return n < 10 ? "0" + n : n;
			};
			var today = new Date();
			var year = today.getFullYear();
			var month = fill(today.getMonth() + 1);
			var date = fill(today.getDate());
			var url = href + "&ff=" + year + month + date + ".dat";

			gui.appendExtraInfoHTML(' <a id="hint" href="' + url + '">過去ログを検索する</a>');
		}
	},
	setPostCount: function(gui, postLength) {
		var message;
		if (postLength) {
			message = postLength + "件取得";
		} else {
			message = "未読メッセージはありません。";
		}

		gui.setPostCount(message);
	},
	tweakFooter: function(container, hasPost) {
		var i = container.querySelector("p i");
		if (!i) {
			return;
		}
		var numPostsInfo = i.parentNode;
		var buttons = DOM.nextElement("TABLE")(numPostsInfo);
		var end;

		if (buttons && hasPost) {
			// ボタンを残す
			end = numPostsInfo;
		} else {
			// ボタンはないか、必要ないので消す
			end = DOM.nextElement("HR")(numPostsInfo);
		}

		var range = document.createRange();
		range.setStartBefore(numPostsInfo);
		range.setEndAfter(end);

		range.deleteContents();
	},
};

function tree(config, q, body) {
	try {
		if (Env.IS_FIREFOX) {
			var html = body.parentNode;
			html.removeChild(body);
		}

		var gui = new TreeGUI(config, body);
		gui.render();

		var done = Tree.execute(config, q, gui, body);

		Tree.deleteOriginal(config, body);

		gui.prependToBody();

		return done;
	} finally {
		if (Env.IS_FIREFOX) {
			html.appendChild(body);
		}
	}
}

function StackView(config) {
	this.range = document.createRange();
	this.original = document.createElement("div");
	this.original.className = "message original";
	this.thumbnail = new Thumbnail(config);

	this.showButtons = document.createElement("span");
	this.showButtons.className = "showOriginalButtons";

	this.range.selectNodeContents(this.original); // 引数は何でもいいが何かで上書きしないとopera12で<html>...</html>が返る
	this.vanishButton = this.range.createContextualFragment('<a href="javascript:;" class="vanish">消</a>  ');
	this.showNGButton = this.range.createContextualFragment('<a href="javascript:;" class="showNG">NG</a> ');
	this.showThreadButton = this.range.createContextualFragment('<a href="javascript:;" class="showThread">非表示解除</a> ');

	this.needToWrap = config.useVanishThread || config.keyboardNavigation || (window.Intl && Intl.v8BreakIterator); // or blink
	this.useThumbnail = config.thumbnail;
	this.utterlyVanishNGThread = config.utterlyVanishNGThread;
	this.utterlyVanishNGStack = config.utterlyVanishNGStack;
	this.nextComment = DOM.nextSibling("#comment");
	this.makePost = Post.collectEssestialParts();
	this.config = config;
	this.ng = config.ng;
	this.markNG = this.createMarkNG(config.ng);
}
StackView.prototype = {
	setRange: function(start, end) {
		this.range.setStartBefore(start);
		this.range.setEndAfter(end);
	},

	deleteMessage: function(post) {
		var el = post.el;
		var end = this.nextComment(el.blockquote);
		this.setRange(el.anchor, end);
		this.range.deleteContents();
	},

	wrapMessage: function(post) {
		var el = post.el;
		var wrapper = this.original.cloneNode(false);

		this.setRange(el.anchor, el.blockquote);
		this.range.surroundContents(wrapper);

		if (this.config.useVanishThread) {
			var thread = el.threadButton;
			thread.parentNode.insertBefore(this.vanishButton.cloneNode(true), thread);
			wrapper.dataset.threadId = post.threadId;
		}

		return wrapper;
	},

	createMarkNG: function(ng) {
		var word = ng.wordg;
		var handle = ng.handleg;
		var markNG = Posts.markNG(word);
		var markNGHeader = Posts.markNGHeader(handle);

		return function(post) {
			var el = post.el;
			if (word) {
				var data = {
					value: post.text,
					post: post,
				};

				markNG(data);

				el.pre.innerHTML = data.value;
			}

			if (handle) {
				el.name.innerHTML = markNGHeader(post.name);
				el.title.innerHTML = markNGHeader(post.title);
			}
		};
	},

	wrapOne: function(a) {
		var post = this.makePost(a);
		var buttons = [];

		if (this.vanish(post, buttons) === false) {
			return;
		}

		if (this.vanishByNG(post, buttons) === false) {
			return;
		}

		this.buildMessage(post, buttons);
		this.registerThumbnail(post);
	},

	buildMessage: function(post, buttons) {
		if (this.needToWrap || buttons.length) {
			var wrapper = this.wrapMessage(post);

			if (buttons.length) {
				wrapper.classList.add("hidden");
				var showButtons = wrapper.parentNode.insertBefore(this.showButtons.cloneNode(false), wrapper);

				buttons.forEach(function(button) {
					showButtons.appendChild(button.cloneNode(true));
				});
			}
		}
	},
	vanish: function(post, buttons) {
		if (this.config.useVanishThread) {
			if (this.config.vanishedThreadIDs.indexOf(post.threadId) !== -1) {
				if (this.utterlyVanishNGThread) {
					this.deleteMessage(post);
					return false;
				} else {
					buttons.push(this.showThreadButton);
				}
			}
		}
	},

	vanishByNG: function(post, buttons) {
		var ng = this.ng;
		if (ng.isEnabled) {
			Post.checkNG(ng, post);

			if (post.isNG) {
				if (this.utterlyVanishNGStack) {
					this.deleteMessage(post);
					return false;
				} else if (this.config.NGCheckMode) {
					this.markNG(post);
				} else {
					buttons.push(this.showNGButton);
				}
			}
		}
	},

	registerThumbnail: function(post) {
		if (this.useThumbnail) {
			this.thumbnail.register(post.el.pre);
		}
	},
};

function StackLog(config, q, body, view) {
	this.config = config;
	this.q = q;
	this.body = body;
	this.view = view;
}
StackLog.prototype = {
	container: function() {
		if (!document.body) {
			throw new Error("no body");
		}

		var el = document.createElement("div");
		el.id = "container";
		var info = new Info();
		el.appendChild(info);

		return {container: el, info: info};
	},
	shouldComplement: function() {
		return this.q.m === "t" && /^\d+\.dat$/.test(this.q.ff) && /^\d+$/.test(this.q.s) && !this.body.querySelector('a[name="' + this.q.s + '"]');
	},
	complement: function() {
		if (this.shouldComplement()) {
			var gui = this.container();
			var container = gui.container;
			var info = gui.info;

			info.innerHTML = '<strong>' + this.q.ff + "以外の過去ログを検索中...</strong>";
			this.body.insertBefore(container, this.body.firstChild);

			return this.makeFetch().both(this.body)
			.then(this.addExtraLog.bind(this, container))
			.then(function() {
				info.textContent = "";
			});
		}
	},
	makeFetch: function() {
		return new Fetch(this.q);
	},
	addExtraLog: function(container, doms) {
		var view = this.view;
		var wrap = (function() {
			var wrap = view.wrapOne.bind(view);
			return function(f) {
				Array.prototype.forEach.call(f.querySelectorAll("a[name]"), wrap);
				return f;
			};
		})();
		var f = document.createDocumentFragment();
		function format(f, div) {
			var numberOfPosts = div.querySelectorAll("a[name]").length;

			f.appendChild(DOM('<h1>' + div.ff + '</h1>'));

			if (numberOfPosts) {
				f.appendChild(wrap(div));
				f.appendChild(DOM('<h3>' + numberOfPosts + '件見つかりました。</h3>'));
			} else {
				f.appendChild(DOM('<hr>'));
				f.appendChild(DOM('<h3>指定されたスレッドは見つかりませんでした。</h3><hr>'));
			}

			return f;
		}

		if (doms.befores.length) {
			f.appendChild(DOM('<hr>'));
		}

		f = doms.befores.reduceRight(format, f);

		f.appendChild(DOM('<hr>'));
		f.appendChild(DOM('<h1>' + this.q.ff + '</h1>'));

		this.body.insertBefore(f, container.nextSibling);

		f = doms.afters.reduceRight(format, f);
		this.body.appendChild(f);
	},
};

var Stack = {
	common: function(config, body) {
		Stack.addEventListener(config, body);
		Stack.configButton(config, body);
		Stack.accesskey(config, body);
	},
	accesskey: function(config, body) {
		var midoku = body.querySelector('input[name="midokureload"]');
		if (midoku) {
			midoku.accessKey = config.accesskeyReload;
		}
	},
	addEventListener: function(config, body) {
		on(body, "click", ".showNG", this.showNG);
		on(body, "click", ".showThread", this.showThread.bind(this, config));
		on(body, "click", ".clearVanishedThreadIDs", this.clearVanishedThreadIDs.bind(this, config));
		on(body, "click", ".vanish", this.vanish.bind(this, config));
	},
	showNG: function(e) {
		Stack.removeButtons(e.target.parentNode.nextElementSibling);
	},
	showThread: function(config, e) {
		e.preventDefault();

		var buttons = e.target.parentNode;
		var thisMessage = buttons.nextElementSibling;
		var id = thisMessage.dataset.threadId;
		var restore = Stack.savePosition(buttons);

		config.removeVanishedThread(id);

		Array.prototype.filter.call(document.querySelectorAll('.original'), function(message) {
			return message.dataset.threadId === id;
		}).forEach(function(message) {
			if (message === thisMessage) {
				restore();
			}

			Stack.removeButtons(message);
		});
	},
	clearVanishedThreadIDs: function(config, e) {
		eventHandlers.clearVanishedIDs(config, "clearVanishedThreadIDs", e);
	},
	removeButtons: function(message) {
		var buttons = message.previousElementSibling;
		message.classList.remove("hidden");
		buttons.parentNode.removeChild(buttons);
	},
	vanish: function(config, e) {
		e.preventDefault();

		var message = e.target.closest(".original");
		var id = message.dataset.threadId;
		var data = e.target.classList.contains("revert") ? Stack.doRevertVanish() : Stack.doVanish();
		var restore = Stack.savePosition(message);

		config[data.type + "VanishedThread"](id);

		Array.prototype.filter.call(document.querySelectorAll('.original'), function(message) {
			return message.dataset.threadId === id;
		}).forEach(function(message) {
			message.classList.toggle("message");
			message.querySelector("blockquote").classList.toggle("hidden");

			var button = message.querySelector(".vanish");
			button.classList.toggle("revert");
			button.textContent = data.text;
		});

		restore();
	},
	doVanish: function() {
		return {
			text: "戻",
			type: "add",
		};
	},
	doRevertVanish: function() {
		return {
			text: "消",
			type: "remove",
		};
	},
	savePosition: function(element) {
		var top = element.getBoundingClientRect().top;
		return function restorePosition() {
			window.scrollTo(window.pageXOffset, window.pageYOffset + element.getBoundingClientRect().top - top);
		};
	},
	configButton: function(config, body) {
		var setup = body.querySelector('input[name="setup"]');
		if (setup) {
			var button = ' <a href="javascript:;" id="openConfig">★くわツリービューの設定★</a>';

			if (config.vanishedThreadIDs.length) {
				button += ' 非表示解除(<a class="clearVanishedThreadIDs" href="javascript:;"><span class="length">' + config.vanishedThreadIDs.length + '</span>スレッド</a>)';
			}

			setup.insertAdjacentHTML("afterend", button);
		}
	},
	render: function(config, body, view) {
		if (config.keyboardNavigation || config.thumbnail || config.ng.isEnabled || config.useVanishThread) {
			var anchors = body.querySelectorAll("body > a[name]");
			var wrap = view.wrapOne.bind(view);

			if (Env.IS_FIREFOX) {
				try {
					var html = body.parentNode;

					html.removeChild(body);

					anchors.forEach(wrap);
				} finally {
					html.appendChild(body);
				}
			} else {
				return loop(wrap, anchors);
			}
		}
	},
	tweakFooter: function(config, container, opt_done) {
		if (this.needsToTweakFooter(config)) {
			var insertFooter = this.doTweakFooter(container);

			return Promise.resolve(opt_done).then(insertFooter);
		}
	},
	needsToTweakFooter: function(config) {
		return config.ng.isEnabled && config.utterlyVanishNGStack ||
			config.useVanishThread && config.utterlyVanishNGThread;
	},
	doTweakFooter: function(container) {
		var i = container.querySelector("p i");

		if (!i) {
			return doNothing;
		}

		var numPostsInfo = i.parentNode;

		var hr = DOM.nextElement("HR")(numPostsInfo);

		var insertionPoint = hr.nextSibling;

		var range = document.createRange();
		range.setStartBefore(numPostsInfo);
		range.setEndAfter(hr);

		var footer = range.extractContents();

		return function insertBack() {
			if (!footer.querySelector('table input[name="pnext"]')) {
				return;
			}

			footer.removeChild(numPostsInfo);
			insertionPoint.parentNode.insertBefore(footer, insertionPoint);
		};
	},
};

function stack(config, q, body) {
	Stack.common(config, body);

	var view = new StackView(config);
	var log = new StackLog(config, q, body, view);

	var complement = log.complement();
	var render = Stack.render(config, body, view);
	var tweakFooter = Stack.tweakFooter(config, body, render);

	return Promise.all([complement, render, tweakFooter]);
}

function Info() {
	var el = document.createElement("span");
	el.id = "info";

	return el;
}

function KeyboardNavigation(config, window) {
	if (!window) {
		throw new Error("missing window");
	}

	//同じキーでもkeypressとkeydownでe.whichの値が違うので注意
	var messages = document.getElementsByClassName("message");
	var focusedIndex = -1;

	if (typeof requestAnimationFrame !== "function") {
		window.requestAnimationFrame = function(callback) {
			setTimeout(callback, 16);
		};
	}

	var done = 0;

	this.setReloadable = function() {
		done = Date.now();
	};

	this.isValid = function(index) {
		return !!messages[index];
	};

	// jQuery 2系 jQuery.expr.filters.visibleより
	function isVisible(elem) {
		return elem.offsetWidth > 0 || elem.offsetHeight > 0 || elem.getClientRects().length > 0;
	}
	function isHidden(elem) {
		return !isVisible(elem);
	}

	this.indexOfNextVisible = function(index, dir) {
		var el = messages[index];
		if (el && isHidden(el)) {
			return this.indexOfNextVisible(index + dir, dir);
		}
		return index;
	};

	var isUpdateScheduled = false;
	this.updateIfNeeded = function() {
		if (isUpdateScheduled) {
			return;
		}

		isUpdateScheduled = true;

		requestAnimationFrame(this.changeFocusedMessage);
	};
	this.changeFocusedMessage = function() {
		var m = messages[focusedIndex];
		var top = m.getBoundingClientRect().top;
		var x = window.pageXOffset;
		var y = window.pageYOffset;

		var focused = document.getElementsByClassName("focused")[0];
		if (focused) {
			focused.classList.remove("focused");
		}
		m.classList.add("focused");
		window.scrollTo(x, top + y - config.keyboardNavigationOffsetTop);

		isUpdateScheduled = false;
	};

	this.focus = function(dir) {
		var index = this.indexOfNextVisible(focusedIndex + dir, dir);
		if (this.isValid(index)) {
			focusedIndex = index;
			this.updateIfNeeded();
		} else if (dir === 1) {
			var now = Date.now();
			if (done && now - done >= 500) {
				done = now;
				eventHandlers.midokureload();
			}
		}
	};

	this.res = function() {
		var focused = document.querySelector(".focused");
		if (!focused) {
			return;
		}

		var selector;
		if (focused.classList.contains("original")) {
			selector = "font > a:first-child";
		} else {
			selector = ".res";
		}

		var res = focused.querySelector(selector);
		if (res) {
			if (typeof GM_openInTab === "function") {
				GM_openInTab(res.href, false);
			} else {
				window.open(res.href);
			}
		}
	};

	this.handleEvent = function(e) {
		var target = e.target;

		if (/^(?:INPUT|SELECT|TEXTAREA)$/.test(target.nodeName) || target.isContentEditable) {
			return;
		}

		switch (e.which) {
			case 106: //j
				this.focus(1);
				break;
			case 107: //k
				this.focus(-1);
				break;
			case 114: //r
				this.res();
				break;
			default:
		}
	};
}

///////////////////////////////////////////////////////////////////////////////

AppCommon.injectCSS = function(config) {
	var css = '\
		.text {\
			white-space: pre-wrap;\
		}\
		.text, .extra {\
			min-width: 20rem;\
		}\
		.text_tree-mode-css, .extra_tree-mode-css {\
			margin-left: 1rem;\
		}\
		.env {\
			font-family: initial;\
			font-size: smaller;\
		}\
		.message_tree-mode-css, .border, .showMessage_tree-mode-css {\
			position: relative;\
		}\
\
		.thread-header {\
			background: #447733 none repeat scroll 0 0;\
			border-color: #669955 #225533 #225533 #669955;\
			border-style: solid;\
			border-width: 1px 2px 2px 1px;\
			font-size: 0.8rem;\
			font-family: normal;\
			margin-top: 0.8rem;\
			padding: 0;\
			width: 100%;\
		}\
\
		.message-header {\
			white-space: nowrap;\
		}\
		.message-header_tree-mode-css {\
			font-size: 0.85rem;\
			font-family: normal;\
		}\
		.message-info {\
			font-family: monospace;\
			color: #87CE99;\
		}\
\
		.read, .quote {\
			color: #CCB;\
		}\
		header, footer {\
			display: flex;\
			font-size: 0.9rem;\
		}\
		header .left, footer .left {\
			margin-right: auto;\
		}\
		.thread {\
			margin-bottom: 1rem;\
		}\
		.modified {\
			color: #FBB\
		}\
		.note, .characterEntityOn, .env {\
			font-style: italic;\
		}\
		.chainingHidden::after {\
			content: "この投稿も非表示になります";\
			font-weight: bold;\
			font-style: italic;\
			color: red;\
		}\
		.a-tree {\
			font-style: initial;\
		}\
\
		.inner {\
/*			border: 2px solid yellow; */\
			top: -1rem;\
		}\
		.outer {\
			border-left: 1px solid #ADB;\
			top: 1rem;\
		}\
		.thumbnail-img {\
			width: 80px;\
			max-height: 400px;\
			image-orientation: from-image;\
		}\
		#image-view {\
			position: fixed;\
			top: 50%;\
			left: 50%;\
			transform: translate(-50%, -50%);\
			background: #004040;\
			color: white;\
			font-weight: bold;\
			font-style: italic;\
			margin: 0;\
			image-orientation: from-image;\
		}\
		.image-view-img {\
			background-color: white;\
		}\
\
		.focused {\
			border: 2px solid yellow;\
		}\
		.truncation, .NGThread .messages, .hidden {\
			display: none;\
		}\
		.spacing {\
			padding-bottom: 1rem;\
		}\
	';
	GM_addStyle(css + config.css);
};

function GM_addStyle(css) {
	var doc = document;
	var head = doc.getElementsByTagName("head")[0];
	var style = null;
	if (head) {
		style = doc.createElement("style");
		style.textContent = css;
		head.appendChild(style);
	}
}

var div_ = document.createElement("div");
function DOM(html) {
	var div = div_.cloneNode(false);
	div.innerHTML = html;
	return div.firstChild;
}
DOM._next = function(type) {
	type = "next" + type;
	return function(nodeName) {
		return function next(node) {
			node = node[type];

			while (node) {
				if (node.nodeName === nodeName) {
					return node;
				}

				node = node[type];
			}
		};
	};
};
DOM.nextElement = DOM._next("ElementSibling");
DOM.nextSibling = DOM._next("Sibling");
DOM.fetch = function(options) {
	return ajax(options)
		.then(DOM.wrapWithDiv)
		.catch(DOM.wrapErrorWithDiv);
};
DOM.wrapWithDiv = function wrapWithDiv(html) {
	var div = document.createElement("div");
	div.innerHTML = html;
	return div;
};
DOM.wrapErrorWithDiv = function(error) {
	var div = document.createElement("div");
	div.textContent = error;
	return div;
};

function loop(func, array) {
	return new Promise(function(resolve, reject) {
		var i = 0, length = array.length;
		var done = [];

		(function loop() {
			var t = Date.now();
			do {
				if (i === length) {
					Promise.all(done).then(resolve);
					return;
				}

				try {
					done.push(func(array[i++]));
				} catch (e) {
					reject(e);
					return;
				}
			} while (Date.now() - t < 20);
			setTimeout(loop, 0);
		})();
	});
}

/*exported parseQuery*/
function parseQuery(search) {
	var obj = {}, kvs = search.substring(1).split("&");
	kvs.forEach(function (kv) {
		obj[kv.split("=")[0]] = kv.split("=")[1];
	});
	return obj;
}

/*eslint-env es6 */
function delayPromise(ms) {
	return new Promise(function(resolve) {
		setTimeout(resolve, ms);
	});
}

function DelayNotice(config, loaded, body, timeout) {
	var this$1 = this;

	this.config = config;
	this.loaded = loaded;
	this.body = body;
	this.timeout = delayPromise(timeout || 700);

	config.then(function () {
		this$1.configIsLoaded = true;
	});
}
DelayNotice.prototype.start = function() {
	var this$1 = this;

	return Promise.race([this.timeout, this.loaded])
	.then(function () { return this$1.body; })
	.then(this.popup.bind(this));
};
DelayNotice.prototype.popup = function(body) {
	if (this.configIsLoaded) {
		return;
	}

	var notice = document.createElement("aside");
	notice.id = "qtv-status";
	notice.style.cssText = "position:fixed;top:0px;left:0px;background-color:black;color:white;z-index:1";
	notice.textContent = '設定読込待ち';

	body.insertBefore(notice, body.firstChild);

	var removeNotice = function() {
		body.removeChild(notice);
	};

	this.config.then(removeNotice, removeNotice);

	this.loaded.then(function() {
		notice.textContent = "設定読込待ちかレンダリング中";
	});
};

/*global App*/
/*exported AppChrome*/
var AppChrome = {
	main: function main(config, q) {
		var body = AppChrome.waitFor.body(document);
		var loaded = AppChrome.waitFor.loaded(window);
		var observer = new Observer(document, loaded);
		var handler = new Handler(config, q, body, loaded);
		var notice = new DelayNotice(config, loaded, body, 700);

		var done = handler.start();
		notice.start();

		observer.listener = handler;

		observer.observe();

		return done;
	},
	waitFor: {
		body: function body(document) {
			return new Promise(function(resolve) {
				if (document.body) {
					resolve(document.body);
					return;
				}

				var observer = new MutationObserver(function(mutations) {
					mutations.forEach(function(mutation) {
						Array.prototype.forEach.call(mutation.addedNodes, function(node) {
							if (node.nodeName === "BODY") {
								observer.disconnect();
								resolve(node);
							}
						});
					});
				});

				observer.observe(document.documentElement, {childList: true});
			});
		},
		loaded: function loaded(window) {
			return new Promise(function (resolve) {
				window.addEventListener("DOMContentLoaded", function resolver(e) {
					window.removeEventListener(e.type, resolver, true);
					resolve();
				}, true);
			});
		},
	},
};

/*global Stack*/
var StreamStackView = function StreamStackView(args) {
	Object.assign(this, args);

	this.r = document.createRange();
};
StreamStackView.prototype.init = function init () {
	var ms = this.ms;
	Stack.common(this.config, this.body);

	if (ms.hasChildNodes()) {
		var range = this.r;
		range.selectNodeContents(ms);
		this.buffer.appendChild(range.extractContents());
	}

	this.render();
	ms.hidden = false;
};
StreamStackView.prototype.finish = function finish () {
	Stack.tweakFooter(this.config, this.buffer);

	this.body.appendChild(this.buffer);

	return this.log.complement();
};
StreamStackView.prototype.render = function render () {
	var ref = this;
		var r = ref.r;
		var view = ref.view;
		var ms = ref.ms;
		var buffer = ref.buffer;
		var firstComment = ref.firstComment;
	var comment;

	while ((comment = firstComment(buffer))) {
		r.setStartBefore(buffer.firstChild);
		r.setEndAfter(comment);
		// 以下のように一つずつやるとO(n)
		// 一気に全部やるとO(n^2)
		view.wrapOne(buffer.querySelector("a[name]"));
		ms.appendChild(r.extractContents());
	}
};
StreamStackView.prototype.firstComment = function firstComment (buffer) {
	var first = buffer.firstChild;
	while (first) {
		if (first.nodeType === Node.COMMENT_NODE && first.nodeValue === ' ') {
			return first;
		}
		first = first.nextSibling;
	}

	return null;
};

/*global Tree, TreeGUI */
var StreamTreeView = function StreamTreeView(args) {
	Object.assign(this, args);
	this.gui = new TreeGUI(this.config, this.body);
};
StreamTreeView.prototype.init = function init () {
	this.gui.render();
	this.gui.prependToBody();
};
StreamTreeView.prototype.finish = function finish () {
	var ref = this;
		var config = ref.config;
		var gui = ref.gui;
		var buffer = ref.buffer;
		var q = ref.q;
		var ms = ref.ms;
	var container = buffer.hasChildNodes() ? buffer : ms;

	var mDone = Tree.execute(config, q, gui, container);

	this.prepareToggleOriginal(container, mDone);

	this.appendLeftovers(container);

	return mDone;
};
StreamTreeView.prototype.appendLeftovers = function appendLeftovers (container) {
	var leftovers;
	if (container === this.ms) {
		var r = document.createRange();
		r.selectNodeContents(container);
		leftovers = r.extractContents();
	} else if (container === this.buffer) {
		leftovers = container;
	}

	if (leftovers) {
		this.body.appendChild(leftovers);
	}
};
StreamTreeView.prototype.prepareToggleOriginal = function prepareToggleOriginal (container, done) {
	var range = Tree.originalRange(container);

	if (this.config.deleteOriginal) {
		range.deleteContents();
	} else {
		var original = range.extractContents();
		return Promise.all([original, done])
		.then(this.appendToggleOriginal.bind(this));
	}
};
StreamTreeView.prototype.appendToggleOriginal = function appendToggleOriginal (ref) {
		var original = ref[0];
		var posts = ref[1];

	if (!original || !posts.length) {
		return;
	}

	this.appendToggleOriginalButton();
	this.putInOriginal(original);
};
StreamTreeView.prototype.putInOriginal = function putInOriginal (original) {
	this.ms.appendChild(original);
};
StreamTreeView.prototype.appendToggleOriginalButton = function appendToggleOriginalButton () {
	var ms = this.ms;
	var range = document.createRange();
	var fragment = range.createContextualFragment('<div style="text-align:center"><a class="toggleOriginal" href="javascript:;">元の投稿の表示する(時間がかかることがあります)</a></div><hr>');
	var button = fragment.firstChild.firstChild;
	button.addEventListener("click", {ms: ms, handleEvent: this.toggleOriginal});

	ms.parentNode.insertBefore(fragment, ms);
};
StreamTreeView.prototype.toggleOriginal = function toggleOriginal (e, win) {
	win = win || window;
	e.preventDefault();
	e.stopPropagation();
	this.ms.hidden = !this.ms.hidden;
	win.scrollTo(win.pageXOffset, e.target.getBoundingClientRect().top + win.pageYOffset);
};

/*global ErrorHandler, StackView, StackLog */
function Handler(pConfig, q, pBody, pLoaded) {
	var this$1 = this;

	var ms = document.createElement("main");
	ms.id = "qtv-stack";
	ms.hidden = true;

	var buffer = document.createDocumentFragment();
	var bufferRange = document.createRange();

	var view;

	this.onProgress = function (lastChild) {
		if (lastChild === ms) {
			return;
		}

		bufferRange.setEndAfter(lastChild);
		buffer.appendChild(bufferRange.extractContents());

		if (view && "render" in view) {
			view.render();
		}
	};

	this.stash = function() {
		ms.appendChild(buffer);
	};

	this.onHR = function (hr) {
		bufferRange.setStartAfter(hr);
	};

	var pAnchor = new Promise(function (resolve) {
		this$1.onFirstAnchor = function(a) {
			resolve();

			a.parentNode.insertBefore(ms, a);

			bufferRange.setEndBefore(ms);
			ms.parentNode.insertBefore(bufferRange.extractContents(), ms);

			bufferRange.setStartAfter(ms);
		};
	});

	this.stashForNow = function (config) {
		if (!config) {
			this$1.stash();
		}
	};

	this.createView = function(config, body) {
		var args = {config: config, body: body, q: q, ms: ms, buffer: buffer};
		if (config.isTreeView()) {
			return new StreamTreeView(args);
		} else {
			var view = new StackView(config);
			var log = new StackLog(config, q, body, view);

			return new StreamStackView(Object.assign(args, {view: view, log: log}));
		}
	};

	this.initView = function (config, q, body) {
		view = this$1.createView(config, body);
		view.init();

		return pLoaded.then(function () { return view.finish(); });
	};

	this.execute = function (ref) {
		var config = ref[0];
		var body = ref[1];

		return App.execute(config, q, body, this$1.initView);
	};

	this.start = function () {
		Promise.race([pConfig, pLoaded]).then(this$1.stashForNow);

		return Promise.all([
			pConfig,
			pBody,
			Promise.race([pAnchor, pLoaded]) ]).then(this$1.execute).catch(function (e) {
			return pLoaded.then(function () {
				this$1.onProgress = doNothing;
				ErrorHandler.handle(e);
			});
		});
	};
}

/*global doNothing*/
function Observer(htmlDocument, loaded) {
	var this$1 = this;

	var observer;
	this.listener = null;

	this.firstAnchor = null;
	this.hr = null;

	var find = Array.prototype.find;
	var fireEvent = function (event, arg) {
		try {
			return this$1.listener[event](arg);
		} catch (e) {
			ErrorHandler.handle(e);
			observer.disconnect();
			observer.start = doNothing;
		}
	};
	var isAnchor = function(node) {
		return node.name &&
			node.nodeName === "A" &&
			node.attributes.length === 1 &&
			/^\d+$/.test(node.name) &&
			!node.textContent;
	};

	var isHR = function (node) { return node.nodeName === "HR"; };

	var findElement = function (name, predicate, mutation) {
		if (mutation.target.nodeName === "BODY") {
			var element = find.call(mutation.addedNodes, predicate);
			if (element) {
				this$1[name] = element;
				return element;
			}
		}
	};

	var findAnchor = findElement.bind(null, "firstAnchor", isAnchor);
	var findHR = findElement.bind(null, "hr", isHR);

	this.processRecords = function (mutations, observer) {
		observer.disconnect();

		if (!this$1.hr) {
			mutations.some(findHR);

			if (this$1.hr) {
				fireEvent("onHR", this$1.hr);
			}
		}

		if (!this$1.firstAnchor) {
			mutations.some(findAnchor);

			if (this$1.firstAnchor) {
				fireEvent("onFirstAnchor", this$1.firstAnchor);
			}
		}

		if (this$1.hr) {
			fireEvent("onProgress", htmlDocument.body.lastChild);
		}

		observer.start();
	};

	observer = new MutationObserver(this.processRecords);

	observer.start = function() {
		if (htmlDocument.body) {
			this.observe(htmlDocument.body, { childList: true });
		} else {
			this.observe(htmlDocument.documentElement, { childList: true, subtree: true });
		}
	};

	loaded.then(function () {
		observer.start = doNothing;
		var records = observer.takeRecords();
		if (records.length) {
			console.error(records.length);
			this$1.processRecords(records, observer);
		}
		observer.disconnect();
	});

	this.observe = function () {
		observer.start();
	};
}


// ==UserScript==
// @name        tree view for qwerty
// @name:ja     くわツリービュー
// @namespace   strangeworld
// @description あやしいわーるど@みさおの投稿をツリーで表示できます。スタック表示の方にもいくつか機能を追加できます。
// @match       http://misao.on.arena.ne.jp/cgi-bin/bbs.cgi*
// @match       http://misao.mixh.jp/cgi-bin/bbs.cgi*
// @grant       GM_setValue
// @grant       GM_getValue
// @grant       GM_deleteValue
// @grant       GM_listValues
// @grant       GM_xmlhttpRequest
// @grant       GM_openInTab
// @version     10.11
// @run-at      document-start
// ==/UserScript==

/*global parseQuery, whatToDo, Config*/
function main() {
	var q = parseQuery(location.search);
	var action = whatToDo(q, location.hostname);
	var config = Config.instance;

	action(config, q);
}

main();