Oh no
How does it look like during editing? Does the furigana show up above the kanji in the preview?
No, but we have an actual error message this time. Maybe it makes sense to you (because it doesn’t to me )
If you open the console, type in require("pretty-text/engines/discourse-markdown-it")
and hit enter, what is the result?
This line showed up:
So I expanded all the main tags (aka those that show up when all are closed) once because I wasn’t sure if you needed something in them.
Can you try out this version of IME2Furigana?
Script
// ==UserScript==
// @name IME2Furigana
// @namespace ime2furigana
// @version 1.7
// @description Adds furigana markup functionality to Discourse. When inputting kanji with an IME, furigana markup is automatically added.
// @author Sinyaven
// @license MIT-0
// @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 = 1;
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;
if (!bMode) return;
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);
tText.dispatchEvent(new Event("change", {bubbles: true, cancelable: true}));
}
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.textContent = "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.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.textContent = "Convert <ruby> to furigana markup?";
bConvert.textContent = "\u2714";
bCancel.textContent = "\u274C";
dBanner.appendChild(bConvert);
dBanner.appendChild(bCancel);
bConvert.addEventListener("click", () => { rubyToFuriganaMarkup(); removeBanner(); });
bCancel.addEventListener("click", () => { tText.parentElement.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, "<"));
// sanitize rb (">" not allowed except for tags)
rb = rb.map(v => v.replace(greaterThan_regex, ">"));
// sanitize rt ("]" or "}" not allowed)
rt = rt.map(v => v[0] === "[" ? v.replace(/\](?!$)/, "]") : v.replace(/}(?!$)/, "}"));
// 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 = require("pretty-text/engines/discourse-markdown-it").cook;
require("pretty-text/engines/discourse-markdown-it").cook = e((raw, opts) => oldCook(customCook(raw), opts), w);
}
function injectCustomSave(w, e) {
let oldSave = require("discourse/controllers/composer").default.prototype.save;
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.textContent = "#ime2furigana-conversion-banner { transform: translateY(-0.25em); padding: 0.2em 0.6em; border-bottom: 1px solid gray; background-color: var(--tertiary-low, 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);
}
})();
<振>[ふ]り<仮名>[がな]
Preview still just look like the code it inserted…
Edit: yep still code. Will now check console…
2nd Edit: And here is the new error:
You can also try MeddleMonkey to see if it works.
I am looking at Safari-related TamperMonkey issues, here.
- MIght be this one?
Maybe it works with this version:
Script
// ==UserScript==
// @name IME2Furigana
// @namespace ime2furigana
// @version 1.7
// @description Adds furigana markup functionality to Discourse. When inputting kanji with an IME, furigana markup is automatically added.
// @author Sinyaven
// @license MIT-0
// @match https://community.wanikani.com/*
// @inject-into page
// @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 = 1;
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;
if (!bMode) return;
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);
tText.dispatchEvent(new Event("change", {bubbles: true, cancelable: true}));
}
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.textContent = "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.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.textContent = "Convert <ruby> to furigana markup?";
bConvert.textContent = "\u2714";
bCancel.textContent = "\u274C";
dBanner.appendChild(bConvert);
dBanner.appendChild(bCancel);
bConvert.addEventListener("click", () => { rubyToFuriganaMarkup(); removeBanner(); });
bCancel.addEventListener("click", () => { tText.parentElement.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, "<"));
// sanitize rb (">" not allowed except for tags)
rb = rb.map(v => v.replace(greaterThan_regex, ">"));
// sanitize rt ("]" or "}" not allowed)
rt = rt.map(v => v[0] === "[" ? v.replace(/\](?!$)/, "]") : v.replace(/}(?!$)/, "}"));
// 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 = require("pretty-text/engines/discourse-markdown-it").cook;
require("pretty-text/engines/discourse-markdown-it").cook = e((raw, opts) => oldCook(customCook(raw), opts), w);
}
function injectCustomSave(w, e) {
let oldSave = require("discourse/controllers/composer").default.prototype.save;
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.textContent = "#ime2furigana-conversion-banner { transform: translateY(-0.25em); padding: 0.2em 0.6em; border-bottom: 1px solid gray; background-color: var(--tertiary-low, 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);
}
})();
The F stopped showing up again.
The only error is this again “Refused to execute a script because its hash, its nonce, or ‘unsafe-inline’ does not appear in the script-src directive of the Content Security Policy.”
I don’t know enough about bugs or coding to have any idea if that is the bug.
I would prefer to not try every script manager in the apple store. And is it that likely that the safari issues are handled well in other ones? Safari is so often a low priority.
Too bad. It seems that this error shows up when the script manager attempts to inject the script into page
and then falls back to content
. However, when the script is injected into content
, it cannot access the window
object which is needed to add most of the IME2Furigana functionality.
Maybe I can put the code into a separate file and insert a <script>
element referencing it. I’m not sure if I would then again run into CSP issues.
Thank you for all the time you are spending on this (@polv too). Safari *sigh*, amiright?
It’s mostly about Apple company’s stance, AFAIK; like you have to develop in Xcode, and gotta pay yearly fee, even if your app is free; and most importantly, you need a Mac to develop at all.
There are other things like Safari’s JavaScript engine is different from Chrome family, and Firefox family. Microsoft’s Edge eventually decided to use Chrome’s engine. – Well, it is totally better than the time of Netscape Navigator - most websites work fine, though extensions are another issue.
Though, part of the issue today, is Safari trying to be more secure, therefore enforces more Content-Security-Policy (CSP).
Thank you for the explanation.
I will admit that I like that Safari/Apple is trying to be more secure and privacy oriented. Part of me haven’t quite accepted that it also means I can’t have some of the nice things (as easily anyway).
@Sinyaven and @polv Since I’m possibly the only safari user who wants these scripts, don’t worry about it. I will try any new versions you throw at me, but it is fine if this is it.
I’ve lived without these scripts so far, and I can keep doing that. ^^
If you still have the “Userscripts” extension installed and are up for more testing, here is a version which tries to fall back to a different method if the original one fails:
Script
// ==UserScript==
// @name IME2Furigana
// @namespace ime2furigana
// @version 1.7
// @description Adds furigana markup functionality to Discourse. When inputting kanji with an IME, furigana markup is automatically added.
// @author Sinyaven
// @license MIT-0
// @match https://community.wanikani.com/*
// @homepageURL https://community.wanikani.com/t/39109
// @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><span class='spoiler'>$2</span></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_BBCODE_REGEX = /^\[spoiler\]([^]*)\[\/spoiler\]$/;
const SPOILER_HTML_REGEX = /^<span\b[^>]*\bclass\s*=\s*["'][^"']*\bspoiler\b[^"']*["'][^>]*>([^]*)<\/span>$/;
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 = 1;
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;
if (!bMode) return;
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);
tText.dispatchEvent(new Event("change", {bubbles: true, cancelable: true}));
}
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);
textArea.addEventListener("keydown", e => e.ctrlKey && e.shiftKey && e.key.toUpperCase() === "F" ? cycleMode() : undefined);
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.textContent = "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.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.textContent = "Convert <ruby> to furigana markup?";
bConvert.textContent = "\u2714";
bCancel.textContent = "\u274C";
dBanner.appendChild(bConvert);
dBanner.appendChild(bCancel);
bConvert.addEventListener("click", () => { rubyToFuriganaMarkup(); removeBanner(); });
bCancel.addEventListener("click", () => { tText.parentElement.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_BBCODE_REGEX.test(v) ? ("{" + SPOILER_BBCODE_REGEX.exec(v)[1] + "}") : SPOILER_HTML_REGEX.test(v) ? ("{" + SPOILER_HTML_REGEX.exec(v)[1] + "}") : ("[" + v + "]"));
// sanitize rb ("<" not allowed except for tags)
rb = rb.map(v => v.replace(/<(?!\/?\b[^<>]*>)/g, "<"));
// sanitize rb (">" not allowed except for tags)
rb = rb.map(v => v.replace(greaterThan_regex, ">"));
// sanitize rt ("]" or "}" not allowed)
rt = rt.map(v => v[0] === "[" ? v.replace(/\](?!$)/, "]") : v.replace(/}(?!$)/, "}"));
// 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() {
// greasemonkey workaround: unsafeWindow + exportFunction
let w = typeof unsafeWindow === "undefined" ? window : unsafeWindow;
let e = typeof exportFunction === "undefined" ? o => o : exportFunction;
if (!w.require) { contentContextFallback(); return; }
if (alreadyInjected) return;
alreadyInjected = true;
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) { applyCustomCookToInput(); oldSave.call(this, t); }, w);
}
function customCook(raw) {
if (!mode) {
removeBanner();
return raw;
}
ASK_BEFORE_CONVERTING_RUBY_TO_FURIGANA_MARKUP ? checkForRubyTags() : rubyToFuriganaMarkup();
let halfCooked = raw.replace(COOK_SEARCH_REGEX, RUBY_TEMPLATE);
halfCooked = halfCooked.replace(COOK_SPOILER_SEARCH_REGEX, RUBY_SPOILER_TEMPLATE);
bMode.classList.toggle("markup-found", halfCooked !== raw);
return halfCooked;
}
function applyCustomCookToInput() {
tText.value = customCook(tText.value);
tText.dispatchEvent(new Event("change", {bubbles: true, cancelable: true}));
bMode.classList.remove("markup-found");
}
// ---FALLBACK IF WE CANNOT ACCESS JAVASCRIPT OBJECTS FROM PAGE CONTEXT--- //
function contentContextFallback() {
console.warn("IME2Furigana: No access to objects in page context. Using fallback method which might be a bit slower, can cause text flickering, and pauses the instant preview during IME input.");
const bSave = document.querySelector(".save-or-cancel button");
let fallbackIgnoreChange = false;
async function callback() {
if (fallbackIgnoreChange || !mode) return;
fallbackIgnoreChange = true;
const original = tText.value;
const selectionStart = tText.selectionStart;
const selectionEnd = tText.selectionEnd;
const selectionDirection = tText.selectionDirection;
applyCustomCookToInput();
await true;
tText.value = original;
tText.setSelectionRange(selectionStart, selectionEnd, selectionDirection);
fallbackIgnoreChange = false;
}
function delayedCallback() {
if (fallbackIgnoreChange || !mode) return;
setTimeout(callback);
}
tText.addEventListener("compositionstart", () => { fallbackIgnoreChange = true; });
tText.addEventListener("compositionend" , () => { fallbackIgnoreChange = false; });
tText.addEventListener("input" , callback);
tText.addEventListener("change" , delayedCallback);
tText.addEventListener("keyup" , delayedCallback);
bSave.addEventListener("click" , applyCustomCookToInput);
}
// ---ADD CSS--- //
function addCss() {
let style = document.createElement("style");
style.textContent = `
#ime2furigana-conversion-banner { transform: translateY(-0.25em); padding: 0.2em 0.6em; border-bottom: 1px solid gray; background-color: var(--tertiary-low, rgba(163, 225, 255, 0.5)); }
#ime2furigana-conversion-banner > button { background-color: transparent; border: none; }
#ime2furigana-button.active.markup-found { border-bottom: 4px solid var(--tertiary, blue); padding-bottom: calc(0.5em - 3px); }
#ime2furigana-button.active { background-color: #00000042; }
#ime2furigana-button.blur { filter: blur(2px); }`;
document.head.appendChild(style);
}
})();
振り仮名 出来る?そうだね。良かった!
Well, it seems to work with userscripts now.
Even if the extension itself was being annoying to get to work again; something about the old permission to work on community.wanikani messing with allowing it this time, until I turned off and turned on the permission.
Any chance you can get this to work with Tampermonkey? Actually, I’m gonna go see if it works with tampermonkey… And it turns out as kinda expected (since you weren’t trying to fix it for tampermonkey) that the F doesn’t show up.
Sorry, but it seems that the Tampermonkey version for Safari does not manage to inject scripts on the forums at all, so there’s nothing I can do.
Ah, well, doh.
Well, that version does work with Userscripts. When editing a message I had to add/delete a letter for the banner about turning it back into the easy format to show up, but maybe that is true for other versions too. I thought I should mention it though.