IME2Furigana

Adds furigana markup functionality to Discourse. When inputting kanji with an IME, furigana markup is automatically added.

当前为 2020-09-17 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         IME2Furigana
// @namespace    ime2furigana
// @version      1.5
// @description  Adds furigana markup functionality to Discourse. When inputting kanji with an IME, furigana markup is automatically added.
// @author       Sinyaven
// @match        https://community.wanikani.com/*
// @grant        none
// ==/UserScript==

(async function() {
    "use strict";

	/* global require, exportFunction */
    /* eslint no-multi-spaces: "off" */

	//////////////
	// settings //
	//////////////

	const ASK_BEFORE_CONVERTING_RUBY_TO_FURIGANA_MARKUP = true;

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

	const DISCOURSE_REPLY_BOX_ID = "reply-control";
	const DISCOURSE_REPLY_AREA_CLASS = "reply-area";
	const DISCOURSE_BUTTON_BAR_CLASS = "d-editor-button-bar";
	const NO_BACK_CONVERSION_CLASS_FLAG = "ruby-to-furigana-markup-disabled";
	const RUBY_TEMPLATE = "<ruby lang = 'ja-JP'>$1<rp>(</rp><rt>$2</rt><rp>)</rp></ruby>";
	const RUBY_SPOILER_TEMPLATE = "<ruby lang = 'ja-JP'>$1<rp>(</rp><rt>[spoiler]$2[/spoiler]</rt><rp>)</rp></ruby>";
	const FURIGANA_REGEX = /^[\u3041-\u3096\u3000-\u303f\uff01-\uff5e¥]+$/;
	const KANJI_REGEX = /([\uff66-\uff9d\u4e00-\u9faf\u3400-\u4dbf]+)/;
	const RUBY_REGEX = /<ruby\b[^>]*>((?:(?!<\/?ruby\b)[^])+)<\/ruby>/; // using [^] as a complete wildcard (as opposed to . which does not match newlines without the dotAll flag)
	const SPOILER_REGEX = /^\[spoiler\]([^]*)\[\/spoiler\]$/;
	const COOK_SEARCH_REGEX = /<(?!\s)((?:<\/?\b[^<>]*>(?!\[)|[^<>])*)>\[(?!spoiler\s*\])([^\]]*)\]/g;
	const COOK_SPOILER_SEARCH_REGEX = /<(?!\s)((?:<\/?\b[^<>]*>(?!{)|[^<>])*)>{([^}]*)}/g;

	// negative lookbehind might not be supported (e.g. Waterfox) - in that case use an insufficient regex and hope for the best
	let greaterThan_regex = null;
	try { greaterThan_regex = new RegExp("(?<!<\\/?\\b[^<>]*)>", "g"); } catch (e) { greaterThan_regex = /^>/g; }

	let mode = 0;
	let furigana = "";
	let bMode = null;
	let tText = null;
	let dBanner = null;
	let alreadyInjected = false;

	// ---STORAGE--- //

	mode = parseInt(localStorage.getItem("furiganaMode")) || mode;
	addEventListener("storage", e => e.key === "furiganaMode" ? modeValueChangeHandler(parseInt(e.newValue)) : undefined);

	function modeValueChangeHandler(newValue) {
		mode = newValue;
		updateButton();
		// trigger _updatePreview() by appending a space, dispatching a change event, and then removing the space
		let textValue = tText.value;
		let selectionStart = tText.selectionStart;
		let selectionEnd = tText.selectionEnd;
		let selectionDirection = tText.selectionDirection;
		tText.value += " ";
		tText.dispatchEvent(new Event("change", {bubbles: true, cancelable: true}));
		tText.value = textValue;
		tText.setSelectionRange(selectionStart, selectionEnd, selectionDirection);
	}

	function setModeValue(newValue) {
		modeValueChangeHandler(newValue);
		localStorage.setItem("furiganaMode", mode);
	}

	// ---REPLY BOX AND TEXT AREA DETECTION--- //

	let dObserverTarget = await waitFor(DISCOURSE_REPLY_BOX_ID, 1000, 30); // Greasemonkey seems to inject script before reply box is available, so we might have to wait
	let observer = new MutationObserver(m => m.forEach(handleMutation));
	observer.observe(dObserverTarget, {childList: true, subtree: true});

	addCss();

	// text area might already be open
	setupForTextArea(document.querySelector("textarea.d-editor-input"));
	addButton(document.getElementsByClassName(DISCOURSE_BUTTON_BAR_CLASS)[0]);

	function handleMutation(mutation) {
		let addedNodes = Array.from(mutation.addedNodes);
		let removedNodes = Array.from(mutation.removedNodes);
		// those forEach() are executed at most once
		addedNodes.filter(n => n.tagName === "TEXTAREA").forEach(setupForTextArea);
		addedNodes.filter(n => n.classList && n.classList.contains(DISCOURSE_BUTTON_BAR_CLASS)).forEach(addButton);
		removedNodes.filter(n => n.classList && n.classList.contains(DISCOURSE_REPLY_AREA_CLASS)).forEach(cleanup);
	}

	function setupForTextArea(textArea) {
		if (!textArea) return;
		tText = textArea;
		textArea.addEventListener("compositionupdate", update);
		textArea.addEventListener("compositionend", addFurigana);
		injectIntoDiscourse();
	}

	async function waitFor(elementId, checkInterval = 1000, waitCutoff = Infinity) {
		let result = null;
		while (--waitCutoff > 0 && !(result = document.getElementById(elementId))) await sleep(checkInterval);
		return result;
	}

	function sleep(ms) {
		return new Promise(resolve => setTimeout(resolve, ms));
	}

	// ---MAIN LOGIC--- //

	function addButton(div) {
		if (!div || (bMode && bMode.parentElement === div)) return;
		bMode = document.createElement("button");
		bMode.id = "ime2furigana-button";
		bMode.className = "btn no-text btn-icon ember-view";
		bMode.innerText = "F";
		updateButton();
		bMode.addEventListener("click", cycleMode);
		div.appendChild(bMode);
	}

	function cycleMode() {
		setModeValue(mode > 1 ? 0 : mode + 1);
		if (tText) tText.focus();
	}

	function updateButton() {
		bMode.classList.toggle("active", mode);
		bMode.classList.toggle("blur", mode === 2);
		bMode.title = "IME2Furigana - " + (mode ? (mode === 1 ? "on" : "blur") : "off");
	}

	function update(event) {
		if (FURIGANA_REGEX.test(event.data)) {
			furigana = event.data;
		}
	}

	function addFurigana(event) {
		if (!mode || event.data.length === 0) return;
		furigana = furigana.replace(/n/g, "ん");
		let parts = event.data.split(KANJI_REGEX);
		if (parts.length === 1) return;
		let hiraganaParts = parts.map(p => Array.from(p).map(c => katakanaToHiragana(c)).join(""));
		let regex = new RegExp("^" + hiraganaParts.map((p, idx) => "(" + (idx & 1 ? ".+" : p) + ")").join("") + "$");
		let rt = furigana.match(regex);
		if (!rt) {
			parts = [event.data];
			rt = [null, furigana];
		}
		rt.shift();
		let rtStart = mode === 2 ? "{" : "[";
		let rtEnd   = mode === 2 ? "}" : "]";
		let markup  = parts.map((p, idx) => idx & 1 ? "<" + p + ">" + rtStart + rt[idx] + rtEnd : p).join("");
		event.target.setRangeText(markup, event.target.selectionStart - event.data.length, event.target.selectionStart, "end");
	}

	function katakanaToHiragana(k) {
		let c = k.charCodeAt(0);
		return c >= 12449 && c <= 12531 ? String.fromCharCode(k.charCodeAt(0) - 96) : k;
	}

	function cleanup() {
		furigana = "";
		bMode = null;
		tText = null;
		dBanner = null;
	}

	// ---CONVERTING BACK TO FURIGANA MARKUP--- //

	function removeBanner() {
		if (dBanner) dBanner.parentElement.removeChild(dBanner);
		dBanner = null;
	}

	function checkForRubyTags() {
		if (tText.parentElement.classList.contains(NO_BACK_CONVERSION_CLASS_FLAG)) return;
		if (!RUBY_REGEX.test(tText.value)) return removeBanner();
		if (dBanner) return;
		dBanner = document.createElement("div");
		let bConvert = document.createElement("button");
		let bCancel = document.createElement("button");
		dBanner.id = "ime2furigana-conversion-banner";
		dBanner.innerText = "Convert <ruby> to furigana markup?";
		bConvert.innerText = "\u2714";
		bCancel.innerText = "\u274C";
		dBanner.appendChild(bConvert);
		dBanner.appendChild(bCancel);
		bConvert.addEventListener("click", () => { rubyToFuriganaMarkup(); removeBanner(); });
		bCancel.addEventListener("click", () => { tText.parentElement.classList.add(NO_BACK_CONVERSION_CLASS_FLAG); removeBanner(); });
		tText.insertAdjacentElement("beforebegin", dBanner);
	}

	function rubyToFuriganaMarkup() {
		let parts = tText.value.split(RUBY_REGEX);
		if (parts.length === 1) return;
		tText.value = parts.map((p, idx) => idx & 1 ? rubyContentToFuriganaMarkup(p) : p).join("");
		tText.dispatchEvent(new Event("change", {bubbles: true, cancelable: true}));
	}

	function rubyContentToFuriganaMarkup(ruby) {
		// should be able to handle both interleaved and tabular markup
		// remove <rp>...</rp> or <rp>...<rt>
		ruby = ruby.split(/<rp\s*>/).map((part, idx) => idx === 0 ? part : part.substr(part.search(/<\/rp\s*>|<rt\s*>/))).join("").replace(/<\/rp\s*>/g, "");
		// get rt content
		let rt = ruby.split(/<rt\s*>/).map(part => part.substr(0, part.concat("<rb>").search(/<rb\s*>|<\/rt\s*>/)));
		rt.shift();
		// get rb content
		let rb = ruby.split(/(?:<\/rt\s*>\s*)?<rb\s*>|<\/rt\s*>/).map(part => part.substr(0, part.concat("<rt>").search(/(?:<\/rb\s*>\s*)?<rt\s*>/))).filter(part => !/^\s*$/.test(part));
		// add furigana markup brackets to rt
		rt = rt.map(v => SPOILER_REGEX.test(v) ? ("{" + SPOILER_REGEX.exec(v)[1] + "}") : ("[" + v + "]"));
		// sanitize rb ("<" not allowed except for tags)
		rb = rb.map(v => v.replace(/<(?!\/?\b[^<>]*>)/g, "&lt;"));
		// sanitize rb (">" not allowed except for tags)
		rb = rb.map(v => v.replace(greaterThan_regex, "&gt;"));
		// sanitize rt ("]" or "}" not allowed)
		rt = rt.map(v => v[0] === "[" ? v.replace(/\](?!$)/, "&rsqb;") : v.replace(/}(?!$)/, "&rcub;"));
		// pad rt/rb to be the same length
		let result = rb.reduce((total, v, idx) => total + "<" + v + ">" + (rt[idx] || "[]"), "");
		result += rt.slice(rb.length).reduce((total, v) => total + "<>" + v, "");
		return result;
	}

	// ---COOKING RULE INJECTION--- //

	function injectIntoDiscourse() {
		if (alreadyInjected) return;
		alreadyInjected = true;
		// greasemonkey workaround: unsafeWindow + exportFunction
		let w = typeof unsafeWindow === "undefined" ? window : unsafeWindow;
		let e = typeof exportFunction === "undefined" ? o => o : exportFunction;
		injectCustomCook(w, e);
		injectCustomSave(w, e);
	}

	function injectCustomCook(w, e) {
		let oldCook = w.require("pretty-text/engines/discourse-markdown-it").cook;
		w.require("pretty-text/engines/discourse-markdown-it").cook = e((raw, opts) => oldCook(customCook(raw), opts), w);
	}

	function injectCustomSave(w, e) {
		let oldSave = w.require("discourse/controllers/composer").default.prototype.save;
		w.require("discourse/controllers/composer").default.prototype.save = e(function(t) { tText.value = customCook(tText.value); tText.dispatchEvent(new Event("change", {bubbles: true, cancelable: true})); oldSave.call(this, t); }, w);
	}

	function customCook(raw) {
		if (!mode) {
			removeBanner();
			return raw;
		}
		ASK_BEFORE_CONVERTING_RUBY_TO_FURIGANA_MARKUP ? checkForRubyTags() : rubyToFuriganaMarkup();
		raw = raw.replace(COOK_SEARCH_REGEX, RUBY_TEMPLATE);
		return raw.replace(COOK_SPOILER_SEARCH_REGEX, RUBY_SPOILER_TEMPLATE);
	}

	// ---ADD CSS--- //

	function addCss() {
		let style = document.createElement("style");
		style.innerText = "#ime2furigana-conversion-banner { transform: translateY(-0.25em); padding: 0.2em 0.6em; border-bottom: 1px solid gray; background-color: rgba(163, 225, 255, 0.5); }" +
			"#ime2furigana-conversion-banner > button { background-color: transparent; border: none; }" +
			"#ime2furigana-button.active { background-color: #00000042; }" +
			"#ime2furigana-button.blur { filter: blur(2px); }";
		document.head.appendChild(style);
	}
})();