IME2Furigana

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

As of 2020-04-01 18:50:33 UTC. See the latest version.

// ==UserScript==
// @name         IME2Furigana
// @namespace    ime2furigana
// @version      1.1
// @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" */

	const DISCOURSE_REPLY_BOX_ID = "reply-control";
	const DISCOURSE_BUTTON_BAR_CLASS = "d-editor-button-bar";
	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 KANJI_REGEX = /([\uff66-\uff9d\u4e00-\u9faf\u3400-\u4dbf]+)/;

	let mode = 0;
	let furigana = "";
	let bMode = null;
	let tText = 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});

	// 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);
		// 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);
	}

	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) return;
		bMode = document.createElement("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.style.backgroundColor = mode ? "#00000042" : "";
		bMode.style.filter = mode === 2 ? "blur(2px)" : "";
		bMode.title = "IME2Furigana - " + (mode ? (mode === 1 ? "on" : "blur") : "off");
	}

	function update(event) {
		if (!KANJI_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;
	}

	// ---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) return raw;
		raw = raw.replace(/<([^>]*)>\[([^\]]*)]/g, RUBY_TEMPLATE);
		return raw.replace(/<([^>]*)>{([^}]*)}/g, RUBY_SPOILER_TEMPLATE);
	}
})();