Google 検索窓を複製

Also shows the search box to the page bottom.

// ==UserScript==
// @name        Google 検索窓を複製
// @name:ja     Google 検索窓を複製
// @description Also shows the search box to the page bottom.
// @description:ja 検索窓をページ下部にも表示します。
// @namespace   http://userscripts.org/users/347021
// @version     3.0.2
// @include     https://www.google.*/*
// @include     https://www.google.*/?*
// @include     https://www.google.*/#*
// @include     https://www.google.*/webhp
// @include     https://www.google.*/webhp?*
// @include     https://www.google.*/webhp#*
// @include     https://www.google.*/search*
// @include     https://www.google.*/search?*
// @include     https://www.google.*/search#*
// @exclude     https://www.google.*/search?*tbm=isch*
// @require     https://greasyfork.org/scripts/17895/code/polyfill.js?version=189394
// @require     https://greasyfork.org/scripts/19616/code/utilities.js?version=230651
// @require     https://greasyfork.org/scripts/17896/code/start-script.js?version=112958
// @license     Mozilla Public License Version 2.0 (MPL 2.0); https://www.mozilla.org/MPL/2.0/
// @compatible  Edge 非推奨 / Deprecated
// @compatible  Firefox
// @compatible  Opera
// @compatible  Chrome
// @grant       dummy
// @run-at      document-start
// @icon        data:image/vnd.microsoft.icon;base64,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
// @author      100の人
// @homepage    https://greasyfork.org/scripts/274
// ==/UserScript==

(function () {
'use strict';

class GoogleBottomSearchBox
{
	/**
	 * messageイベントで使用する識別子。
	 * @constant {string}
	 */
	static get ID() {return 'google-bottom-search-box-137';}

	constructor()
	{
		startScript(
			() => {
				if (document.querySelector('#csi + script, #csi + a')) {
					// インスタント検索が有効
					this.main();
				} else if (location.pathname === '/search') {
					// インスタント検索が無効
					this.mainWithoutInstant();
				}
			},
			parent => parent.id === 'viewport' || /* インスタンス検索が無効 */ parent === document.body,
			target => target.id === 'main',
			() => document.getElementById('main')
		);
	}

	/**
	 * @param {Event} event
	 */
	handleEvent(event)
	{
		switch (event.type) {
			case 'focus':
				event.target.closest('#sfdiv').classList.add('sbfcn');
				break;
			case 'blur':
				event.target.closest('#sfdiv').classList.remove('sbfcn');
				break;
			case 'mouseup':
				if (event.target.name !== 'q') {
					event.target.closest('form').q.focus();
				}
				break;
			case 'hashchange':
			case 'message':
				if (event.type === 'hashchange'
					|| event.origin === location.origin && typeof event.data === 'object' && event.data !== null
						&& event.data.id === GoogleBottomSearchBox.ID) {
					if (this.getTbm(location) === this.getTbm(new URL(event.oldURL || event.data.oldURL))) {
						document.querySelector('#foot ~ form [name="q"]').value
							= new URLSearchParams(location.hash.replace('#', '')).get('q') || '';
					}
				}
				break;
		}
	}

	/**
	 * @access protected
	 */
	main()
	{
		this.observeFooterInserting(() => {
			if (!document.querySelector('#foot ~ form [name="q"]')) {
				this.cloneForm();
				this.synchronizeSearchWord();
				this.waitSearchControl().then(() => this.setEventListeners());
			}
		});
	}

	/**
	 * URLのtbmパラメータを取得します。
	 * @access protected
	 * @param {(Location|URL)} url
	 * @returns {string}
	 */
	getTbm(url)
	{
		return new URLSearchParams(url.hash.replace('#', '')).get('tbm')
			|| new URLSearchParams(url.search).get('tbm') || '';
	}

	/**
	 * ページにフッタが挿入されるのを監視します。
	 * @access protected
	 * @param {Function} callback - フッタが挿入されるたびに呼び出されるコールバック関数。
	 * @returns {Promise.<void>}
	 */
	observeFooterInserting(callback)
	{
		new MutationObserver(function (mutations, observer) {
			mutations: for (const mutation of mutations) {
				for (const node of mutation.addedNodes) {
					if (node.id === 'cnt' || node.id === 'foot') {
						callback();
						break mutations;
					}
				}
			}
		}).observe(document.getElementById('main'), {childList: true, subtree: true});
	}

	/**
	 * フォームを複製して挿入します。
 	 * @access protected
	 */
	cloneForm()
	{
		document.head.insertAdjacentHTML('beforeend', `<style>
			.hp #foot ~ form {
				/* トップページから完全に切り替わるまではフォームを表示しない */
				display: none;
			}
			#foot ~ form {
				margin-bottom: 1em;
			}
			:not(.mw) > * > #foot ~ form .tsf-p {
				padding-left: 8px;
			}
			#foot ~ form #sfdiv:hover {
				box-shadow: 0 3px 8px 0 rgba(0,0,0,0.2), 0 0 0 1px rgba(0,0,0,0.08);
			}
		</style>`);

		const bottomForm = document.getElementById('tsf').cloneNode(true);
		bottomForm.querySelector('#logocont').remove();
		document.getElementById('foot').after(bottomForm);
	}

	/**
	 * テキスト入力欄が置換されるのを待機し、実行時点で置換されて居れなければ、複製して置換します。
 	 * @access protected
 	 * @returns {Promise.<void>}
	 */
	waitSearchControl()
	{
		return new Promise(function (resolve) {
			if (document.getElementsByClassName('sbib_a')[0]) {
				resolve();
			} else {
				const ancestors = document.getElementsByClassName('lst-c');
				new MutationObserver((mutations, observer) => {
					observer.disconnect();
					const clone = ancestors[0].cloneNode(true);
					clone.getElementsByTagName('input')[0].removeAttribute('autocomplete');
					ancestors[1].replaceWith(clone);
					resolve();
				}).observe(ancestors[0], {childList: true});
			}
		});
	}

	/**
	 * フォーカス時とクリック時のイベントリスナーを設定します。
 	 * @access protected
	 */
	setEventListeners()
	{
		const form = document.querySelector('#foot ~ form');
		// 検索窓にフォーカスが移った時
		const q = form.q;
		q.addEventListener('focus', this);
		q.addEventListener('blur', this);
		// 検索窓をクリックしたとき
		form.getElementsByClassName('sbib_a')[0].addEventListener('mouseup', this);
	}

	/**
	 * 疑似ページ移動時、複製した検索窓に検索語句を反映します。
 	 * @access protected
	 */
	synchronizeSearchWord()
	{
		if (!this.alreadyObserved) {
			this.alreadyObserved = true;
			GreasemonkeyUtils.executeOnUnsafeContext(function (id) {
				History.prototype.pushState = new Proxy(History.prototype.pushState, {
					apply(target, thisArg, argumentsList)
					{
						const oldURL = location.href;
						const returnValue = Reflect.apply(target, thisArg, argumentsList);
						window.postMessage({id, oldURL}, location.origin);
						return returnValue;
					},
				});
			}, [GoogleBottomSearchBox.ID]);
			window.addEventListener('message', this);
			window.addEventListener('hashchange', this);
		}
	}

	/**
	 * インスタント検索無効時の処理。
 	 * @access protected
	 */
	mainWithoutInstant()
	{
		// body要素挿入時に実行し、Google検索のバージョンを判別する
		let textBoxId, inputNodeId, inputParentNodesClassName, textBoxBorderClass, classOnfocuse, previousSiblingId;

		let isTargetParent, isTarget, functionsForFirefox;
		if (document.body.id) {
			if (document.body.getAttribute('marginheight')) {
				// User-AgentがFirefox
				textBoxId = 'tsf';
				inputNodeId = 'lst-ib';
				inputParentNodesClassName = 'lst-d';
				textBoxBorderClass = 'lst-td';
				classOnfocuse = ['lst-d-f'];
			} else {
				// Google Chrome版 (UAがOpera、Google Chrome、IE8以降)
				textBoxId = 'gbqf';
				inputNodeId = 'gbqfq';
				inputParentNodesClassName = 'gbqfqwc';
				textBoxBorderClass = 'gbqfqw';
				classOnfocuse = ['gbqfqwf', 'gsfe_b'];
			}
			previousSiblingId = 'xjs';

			isTargetParent = parent => parent.id === 'foot';
			isTarget = target => target.id === 'xjs';
			functionsForFirefox = {
				isTargetParent: parent => parent.classList.contains('mw'),
				isTarget(target)
				{
					const firstElementChild = target.firstElementChild;
					return firstElementChild && firstElementChild.id === 'foot';
				},
			};
		} else {
			// IE7版 (UAがIE7以下、またはJavaScriptが無効)
			textBoxId = 'tsf';
			previousSiblingId = 'nav';
			isTargetParent = parent => parent.id === 'foot';
			isTarget = target => target.id === 'nav';
			functionsForFirefox = {
				isTargetParent: parent => parent.localName === 'tbody' && parent.parentNode.id === 'mn',
				isTarget(target)
				{
					const cells = target.cells;
					return cells && cells[0] && cells[0].id === 'leftnav';
				},
			};
		}

		startScript(
			function () {
				// スタイルシートの設定
				document.head.insertAdjacentHTML('beforeend', `<style>
					#foot form {
						margin-top: 13px;
					}

					#foot > form {
						margin-bottom: 1em;
					}

					/*------------------------------------
						Firefox版
					*/
					#foot .nojsv {
						display: none;
					}
					#foot .tsf-p {
						width: 631px;
						padding-left: 8px;
					}
					#nav {
						margin-bottom: initial !important;
					}
				</style>`);

				// 検索ボックスを取得
				const original = document.getElementById(textBoxId);
				if (!original) {
					return;
				}

				// 複製
				const bottomForm = original.cloneNode(true);

				// 移動先を取得
				const previousSibling = document.getElementById(previousSiblingId);

				// 挿入
				previousSibling.parentNode.insertBefore(bottomForm, previousSibling.nextSibling);

				let textBoxBorder, textBoxBorderClassList, inputParentNodes, submitButtonClassList;

				// ページ描画後のスクリプトによる書き換えを待機
				if (inputParentNodesClassName) {
					inputParentNodes = document.getElementsByClassName(inputParentNodesClassName);
					startScript(
						function () {
							// 後から挿入された検索窓を複製
							const table = inputParentNodes[0].firstElementChild.cloneNode(true);
							// オートコンプリートを有効に
							table.getElementsByTagName('input')[0].removeAttribute('autocomplete');
							// 下の検索窓を置き換え
							inputParentNodes[1].replaceChild(table, inputParentNodes[1].firstElementChild);
						},
						parent => parent.id === 'gs_lc0',
						target => target.id === inputNodeId,
						() => document.querySelector('#' + inputNodeId + '[style]')
					);
				}

				// 検索窓にフォーカスが移った時
				if (textBoxBorderClass) {
					textBoxBorder = bottomForm.getElementsByClassName(textBoxBorderClass)[0];
					textBoxBorderClassList = textBoxBorder.classList;
					textBoxBorder.addEventListener('focus', function () {
						textBoxBorderClassList.add(...classOnfocuse);
					}, true);

					textBoxBorder.addEventListener('blur', function () {
						textBoxBorderClassList.remove(...classOnfocuse);
					}, true);

					// 検索窓をクリックしたとき
					textBoxBorder.addEventListener('click', function (event) {
						if (event.target.localName !== 'input') {
							bottomForm.elements.namedItem('q').focus();
						}
					});
				}

				// 検索窓にマウスが載ったとき
				const submitButton = bottomForm.getElementsByClassName('gbqfb')[0];
				if (submitButton) {
					submitButtonClassList = submitButton.classList;
					bottomForm.addEventListener('mouseover', function (event) {
						if (textBoxBorder.contains(event.target)) {
							// 検索窓
							textBoxBorderClassList.add('gbqfqw-hvr', 'gsfe_a');
						} else if (submitButton.contains(event.target)) {
							// 検索ボタン
							submitButtonClassList.add('gbqfb-hvr');
						}
					});

					bottomForm.addEventListener('mouseout', function (event) {
						if (!textBoxBorder.contains(event.relatedTarget)) {
							// 検索窓
							textBoxBorderClassList.remove('gbqfqw-hvr', 'gsfe_a');
						}
						if (!submitButton.contains(event.relatedTarget)) {
							// 検索ボタン
							submitButtonClassList.remove('gbqfb-hvr');
						}
					});
				}
			},
			isTargetParent,
			isTarget,
			() => document.getElementById(previousSiblingId),
			functionsForFirefox
		);
	}
}

new GoogleBottomSearchBox();

})();