HatenaBookmarkAdvancedSuggestion-20061114.user.js

今日の朝のエントリ(id:nozom:20061113:1163372094)は自分としては大したことのない修正だったのに、 もブックマークされていてちょっと驚いた。それで、なんだか申し訳ないので、はてなブックマークのタグ補完を改善: タグとコメントを別々に入力できるようにするスクリプトもバージョンアップした。

HatenaBookmarkAdvancedSuggestion-20061114.user.js

主な変更点

  • タブキーで補完するとき、複数候補があったら共通する部分だけを補完するようにした
  • ブックマーク済みのページを開いたときに実行される、コメントからタグへの展開のバグを修正した
// ==UserScript==
// @name           Yet Another Hatena Bookmark Tag Suggestion
// @namespace      http://d.hatena.ne.jp/nozom/
// @description    Improve Tag Suggestion of Hatena Bookmark
// @include        http://b.hatena.ne.jp/add?*
// @include        http://b.hatena.ne.jp/*/edit?*
// ==/UserScript==

// This script is based on tag_suggest.js.

(function() {
const KEY_BS = 8;
const KEY_TAB = 9;
const KEY_ENTER = 13;
const KEY_SHIFT = 16;
const KEY_ESC = 27;
const KEY_LEFT = 37;
const KEY_UP = 38;
const KEY_RIGHT = 39;
const KEY_DOWN = 40;

var candidatesListDiv;
var userTagsListDiv;
var otherTagsListDiv;
var keywordsListDiv;
var commentInput;
var tagInput;

var addedTags = new Array();
var candidates = new Array();
var suggesting = false;
var selectedIndex = 0;
var wordStartPos = 0;

function moveCaret(target, index) {
	target.setSelectionRange(index, index);
}

function getCaretPos(target) {
	return target.selectionStart;
}

function getWordStartPos(target) {
	var str = target.value;
	var pre = str.substring(0, getCaretPos(target));
	if (pre.match(/^(.*, *)/)) {
		return RegExp.$1.length;
	} else {
		return 0;
	}
}

function getNormalizedTags(target) {
	return unsafeWindow.getNormalizedTags(target);
}

function getLowerTags(target) {
	return unsafeWindow.getLowerTags(target);
}

function getAbsoluteLeft(element) {
	return unsafeWindow.getAbsoluteLeft(element);
}

function getAbsoluteTop(element) {
	return unsafeWindow.getAbsoluteTop(element);
}

function updateAddedTags() {
	var tmpTags = tagInput.value.split(/, */);
	tmpTags.pop();
	newTags = new Array();
	for (var i = 0; i < tmpTags.length; i++) {
		var tag = tmpTags[i].replace(/, */, "");
		if (tag != "") {
			newTags.push(tag);
		}
	}
	if (newTags.length != addedTags.length) {
		addedTags = newTags;
		return true;
	}
	return false;
}

function isAdded(tagName) {
	for (var i = 0; i < addedTags.length; i++) {
		if (addedTags[i] == tagName)
			return true;
	}
	return false;
}

function addTag(tagName) {
	tagInput.value = tagInput.value.replace(/(, *)$/, "");
	if (tagInput.value != "")
		tagInput.value += ", ";
	tagInput.value += tagName + ", ";
}

function removeTag(tagName) {
	var s = tagInput.value + ", ";
	tagInput.value = s.replace(/([^ ,]?[^,]*), */g,
				function(t) {
					t = RegExp.$1;
					return (t == "") || (t == tagName) ? "" : t + ", ";
				});
}

function updateCommentTags() {
	var s = "";
	for (var i = 0; i < addedTags.length; i++) {
		s += "[" + addedTags[i] + "]";
	}
	with (commentInput) {
		value = value.replace(/^((\[[^\[\]]+?\])*)/, s);
	}
}

//
// 補完一覧
//

function createCandidateWord(tagName, selected) {
	var tagDiv = document.createElement("div");
	with (tagDiv.style) {
		padding = "1 5 1 5";
		fontSize = "10pt";
	}
	if (selected) {
		tagDiv.style.backgroundColor = "#6666FF";
		tagDiv.style.color = "white";
	}
	tagDiv.appendChild(document.createTextNode(tagName));
	return tagDiv;
}

function removeCandidatesList() {
	var oldCandidatesListDiv = candidatesListDiv.firstChild;
	if (oldCandidatesListDiv)
		oldCandidatesListDiv.parentNode.removeChild(oldCandidatesListDiv);
}

function updateCandidatesList() {
	if (! suggesting || candidates.length==0)
		removeCandidatesList();
	else {
		var div = document.createElement("div");
		with (div.style) {
			marginTop = "3";
			backgroundColor = "white";
			fontFamily = "sans-serif";
			position = "absolute";
			left = getAbsoluteLeft(tagInput);
			top = getAbsoluteTop(tagInput) + tagInput.clientHeight;
			width = tagInput.clientWidth;
		}
		for (var i = 0; i < candidates.length; i++) {
			var selected = (i == selectedIndex);
			div.appendChild(createCandidateWord(candidates[i], selected));
		}
		var tab = document.createElement("div");
		with (tab.style) {
			padding = "1 0 1 5";
			marginTop = "3";
			fontSize = "8pt";
			borderTop = "1px solid #999999";
			color = "#999999";
		}
		
		tab.appendChild(document.createTextNode("決定[Enter][Tab]"));
		if (candidates.length >= 2) {
			tab.appendChild(document.createTextNode(" / 選択[↑][↓]"));
		}
		div.appendChild(tab);
		
		removeCandidatesList();
		candidatesListDiv.appendChild(div);
	}
}

//
// ユーザーのタグ一覧
//

function selectTag(tag) {
	if (tag.wrappedJSObject) tag = tag.wrappedJSObject;
	tag.style.backgroundColor = "#dddddd";
	tag.style.color = "black";
	tag.onmouseover = function() {};
	tag.onmouseout = function() {};
	tag.onmousedown = function() {
		removeTag(this.firstChild.nodeValue);
		updateAllTagsLists();
		updateCommentTags();
	};
	tag.onmouseup = function() {
		tagInput.focus();
		moveCaret(tagInput, tagInput.value.length);
	};
}

function unselectTag(tag) {
	if (tag.wrappedJSObject) tag = tag.wrappedJSObject;
	tag.style.backgroundColor = "white";
	tag.style.color = "#777777";
	tag.onmouseover = function() {
		this.style.backgroundColor = "#eeeeee";
	};
	tag.onmouseout = function() {
		this.style.backgroundColor = "white";
	};
	tag.onmousedown = function() {
		addTag(this.firstChild.nodeValue);
		updateAllTagsLists();
		updateCommentTags();
	};
	tag.onmouseup = function() {
		tagInput.focus();
		moveCaret(tagInput, tagInput.value.length);
	};
}

function appendUserTagsList() {
	if (tags.length == 0) return;
	
	// 選択・非選択
	for (var i = 0; i < tags.length; i++) {
		if (isAdded(tags[i])) {
			var tag = document.getElementById("tag" + i);
			selectTag(tag);
		} else {
			var tag = document.getElementById("tag" + i);
			unselectTag(tag);
		}
	}
}

//
// 他人のタグ一覧
//

function appendOtherTagsList() {
	if (otherTags.length==0) return;
	
	// 選択・非選択
	for (var i = 0; i < otherTags.length; i++) {
		if (isAdded(otherTags[i])) {
			selectTag(document.getElementById("otherTag" + i));
		} else {
			unselectTag(document.getElementById("otherTag" + i));
		}
	}
}

//
// キーワード一覧
//

function appendKeywordsList() {
	if (keywords.length==0) return;
	
	// 選択・非選択
	for (var i = 0; i < keywords.length; i++) {
		if (isAdded(keywords[i])) {
			selectTag(document.getElementById("keyword" + i));
		} else {
			unselectTag(document.getElementById("keyword" + i));
		}
	}
}

//
// ハンドラ
//

function preventDefaultEvent(e) {
	e.preventDefault();
	e.stopPropagation();
}

function getCommonString() {
	var word = candidates[selectedIndex];

	for (var common_length = 0; common_length < word.length; common_length++) {
		var common_string = word.substring(0, common_length + 1).toLowerCase();
		for (var i = 0; i < candidates.length; i++) {
			if (candidates[i].substring(0, common_length + 1).toLowerCase() != common_string)
				return word.substring(0, common_length);
		}
	}

	return word;
}

function insertCompletion(selectOne) {
	if (suggesting) {
		// 文字挿入
		var word;
		if (selectOne || (candidates.length == 1)) {
			word = candidates[selectedIndex] + ", ";
		} else {
			word = getCommonString();
		}
		var currentPos = getCaretPos(tagInput);
		var str = tagInput.value;
		var pre = str.substring(0, getWordStartPos(tagInput));
		var suf = str.substring(currentPos);
		tagInput.value = pre + word + suf;

		// 描画更新
		updateCandidatesList();
		updateAllTagsLists();
		updateCommentTags();

		// フォーカス
		var pos = getWordStartPos(tagInput) + word.length;
		moveCaret(tagInput, pos);
		tagInput.focus();
	} else {
		var value = tagInput.value;
		if (tagInput.value.match(/(, *)$/)) {
			var len = RegExp.$1.length;
			if (len == 2) return false;

			value = value.substring(0, value.length - len);
		}
		value += ", ";

		tagInput.value = value;

		// 描画更新
		// updateCandidatesList();
		updateAllTagsLists();
		updateCommentTags();

		// フォーカス
		var pos = tagInput.value.length;
		moveCaret(tagInput, pos);
		tagInput.focus();
	}

	return true;
}

function onKeyPressHandler(e) {
	var ascii = (e.charCode) ? e.charCode : ((e.which) ? e.which : e.keyCode);
	switch (ascii) {
	case KEY_TAB:
		if ((! candidates) || (candidates.length == 0)) {
			suggesting = false;
			selectedIndex = 0;
		}

		if (suggesting) {
			insertCompletion(false);
		} else {
			if (! insertCompletion(true)) {
				return;
			}
		}
		preventDefaultEvent(e);

		break;

	case KEY_ENTER:
		if ((! candidates) || (candidates.length == 0)) {
			suggesting = false;
			selectedIndex = 0;
		}

		if (suggesting) {
			insertCompletion(true);
			suggesting = false;
		} else {
			if (! insertCompletion(true)) {
				moveCaret(commentInput, commentInput.value.length);
				commentInput.focus();
			}
		}
		preventDefaultEvent(e);
		break;

	}
	
	switch (e.keyCode) {
	case KEY_TAB:
	case KEY_UP:
	case KEY_DOWN:
	// case KEY_LEFT:
	// case KEY_RIGHT:
	// case KEY_ENTER:
		if (suggesting) {
			preventDefaultEvent(e); // for Gecko
		}
		break;
	}
}

function onKeyDownHandler(e) {
	switch (e.keyCode) {
	case KEY_UP:
	case KEY_DOWN:
		if (! suggesting) break;
		if (candidates.length == 0) {
			suggesting = false;
			selectedIndex = 0;
			break;
		}
		
		// 候補選択
		if (e.keyCode == KEY_UP) {
			if (selectedIndex <= 0)
				selectedIndex = candidates.length - 1;
			else
				selectedIndex -= 1;
		} else {
			if (selectedIndex >= candidates.length - 1)
				selectedIndex = 0;
			else
				selectedIndex += 1;
		}

		updateCandidatesList();
		
		preventDefaultEvent(e); // for IE
		break;
		
	case KEY_BS:
		if (! suggesting) break;
		// すべて消したら補完終了
		var currentPos = getCaretPos(tagInput);
		if (getWordStartPos(tagInput) == currentPos) {
			suggesting = false;
		}
		selectedIndex = 0;
		break;
	case KEY_ENTER: // onpressで処理
		break;
	case KEY_SHIFT:
		break;
	default:
		// selectedIndex = 0;
	}
}

function onKeyUpHandler(e) {
	candidates = new Array();
	var currentPos = getCaretPos(tagInput);

	suggesting = true;

	// 候補を絞り込み
	var startPos = getWordStartPos(tagInput);
	if (startPos < currentPos) {
		var unfinishedWord = tagInput.value.substring(startPos, currentPos).toLowerCase();
		for (var i = 0; i < tags.length; i++) {
			if (tags_lower[i].indexOf(unfinishedWord) == 0) {
				if (! isAdded(tags[i])) {
					candidates.push(tags[i]);
				} else {
				}
			}
		}
	}
	
	switch (e.keyCode) {
	case KEY_UP:
	case KEY_DOWN:
	// case KEY_LEFT:
	// case KEY_RIGHT:
	// case KEY_ENTER:
	// case KEY_TAB:
	case KEY_SHIFT:
		break;
	default:
		updateCandidatesList();
		updateAllTagsLists();
		updateCommentTags();
	}
}

function updateAllTagsLists() {
	if (updateAddedTags()) {
		for (var i = 0; i < tags.length; i++) {
			if (isAdded(tags[i])) {
				selectTag(document.getElementById("tag" + i));
			} else {
				unselectTag(document.getElementById("tag" + i));
			}
		}
		for (var i = 0; i < otherTags.length; i++) {
			if (isAdded(otherTags[i])) {
				selectTag(document.getElementById("otherTag" + i));
			} else {
				unselectTag(document.getElementById("otherTag" + i));
			}
		}
		for (var i = 0; i < keywords.length; i++) {
			if (isAdded(keywords[i])) {
				selectTag(document.getElementById("keyword" + i));
			} else {
				unselectTag(document.getElementById("keyword" + i));
			}
		}
	}
}

//
// 初期化
//

function tag_suggest_init() {
	commentInput = unsafeWindow.document.getElementById("comment");
	candidatesListDiv = unsafeWindow.document.getElementById("candidates_list");
	userTagsListDiv = unsafeWindow.document.getElementById("tags_list");
	otherTagsListDiv = unsafeWindow.document.getElementById("othertags_list");
	keywordsListDiv = unsafeWindow.document.getElementById("keywords_list");

	tagInput = unsafeWindow.document.createElement("input");
	tagInput.setAttribute("id", "tag");
	tagInput.setAttribute("size", 50);
	tagInput.setAttribute("autocomplete", "off" );

	var td1 = document.createElement("td");
	td1.setAttribute("class", "label");
	td1.appendChild(document.createTextNode("タグ"));

	var td2 = document.createElement("td");
	td2.appendChild(tagInput);

	var tr = document.createElement("tr");
	tr.appendChild(td1);
	tr.appendChild(td2);

	var trComment = commentInput.parentNode.parentNode;
	trComment.parentNode.insertBefore(tr, trComment);

	if (unsafeWindow["tags"]) {
		tags = getNormalizedTags(unsafeWindow.tags);
		tags_lower = getLowerTags(tags);
		appendUserTagsList();
	}
	if (unsafeWindow["otherTags"]) {
		otherTags = getNormalizedTags(unsafeWindow.otherTags);
		appendOtherTagsList();
	}
	if (unsafeWindow["keywords"]) {
		keywords = getNormalizedTags(unsafeWindow.keywords);
		appendKeywordsList();
	}

	tagInput.onkeydown = onKeyDownHandler;
	tagInput.onkeypress = onKeyPressHandler;
	tagInput.onkeyup = onKeyUpHandler;
	tagInput.focus();

	candidatesListDiv.style.height = "0px";
	unsafeWindow.updateCandidatesList = function() {};

	if (commentInput.value.match(/^((\[[^\[\]]+?\])*)/)) {
		var s = RegExp.$1;
		s = s.replace(/\]\[/g, ", ").replace(/^\[/, "").replace(/\]$/, ", ");
		tagInput.value = s;
		updateAllTagsLists();
	}
}

addEventListener("load", function() {
	if (document.getElementById("tag") == null)
		tag_suggest_init();
}, false);

})();