Just putting this out there before I go to bed. It appears that my assumption was incorrect about the document.body
stuff. Thinking about it now, I’m surprised it didn’t occur to me earlier. Since it’s not the document.body
element that gets replaced, it’s the #turbo-body
element just beneath that in the DOM.
Unfortunately, it isn’t as simple as adding the key listeners to that element instead, however, since they don’t appear to capture them for me. The way I see it, there’s two options:
I’m likely to be forgetting something, but I’m about to go to sleep, so I’ll just leave it at this for now.
I'll paste my earlier version here for easier comparisons
// ==UserScript==
// @name Jitai
// @author @marciska
// @namespace marciska
// @description Displays your WaniKani reviews with randomized fonts (based on original by @obskyr with various fixes applied)
// @version 3.1.3
// @icon https://raw.github.com/marciska/Jitai/master/imgs/jitai.ico
// @match https://www.wanikani.com/*
// @match https://preview.wanikani.com/*
// @license MIT; http://opensource.org/licenses/MIT
// @run-at document-end
// @grant none
// ==/UserScript==
(function(global) {
'use strict';
/* eslint no-multi-spaces: off */
/* global wkof */
//===================================================================
// Variables
//-------------------------------------------------------------------
const script_id = "jitai";
const script_name = "Jitai";
const listenerOptions = { passive: true };
const pageRegex = /^https:\/\/www\.wanikani\.com\/subjects\/(?:review.*\/?|extra_study\?queue_type=(?:recent_lessons|burned_items))$/;
let item_element;
let style_element;
let setup_complete = false;
new MutationObserver((mutations, observer) => {
for (const mutation of mutations) {
for (const addedNode of mutation.addedNodes) {
if (addedNode.nodeName !== 'IFRAME' || !['rikaitan-popup','yomichan-popup'].includes(addedNode.className))
continue;
window.onblur = () => { if (document.activeElement === addedNode || document.activeElement === addedNode.parentElement) { window.setTimeout(() => addedNode.blur(), 1); }};
observer.disconnect();
return;
}
}
}).observe(document.firstElementChild, { childList: true, subtree: true });
// ----- Fonts -----
const example_sentence = '質問:私立探偵 (P.I.) はどんな靴を履いていますか?<br>答え:・・・スニーカー。(笑)';
let font_default;
let font_randomized;
// available fonts
let font_pool = {
// Default OSX fonts
"Hiragino-Kaku-Gothic-Pro" : {full_font_name: "Hiragino Kaku Gothic Pro, ヒラギノ角ゴ Pro W3", display_name: "Hiragino Kaku Gothic Pro", url: 'local', recommended: false},
"Hiragino-Maru-Gothic-Pro" : {full_font_name: "Hiragino Maru Gothic Pro, ヒラギノ丸ゴ Pro W3", display_name: "Hiragino Maru Gothic Pro", url: 'local', recommended: false},
"Hiragino-Mincho-Pro" : {full_font_name: "Hiragino Mincho Pro, ヒラギノ明朝 Pro W3", display_name: "Hiragino Mincho Pro", url: 'local', recommended: false},
// Default Windows fonts
"Meiryo" : {full_font_name: "Meiryo, メイリオ", display_name: "Meiryo", url: 'local', recommended: false},
"MS-PGothic" : {full_font_name: "MS PGothic, MS Pゴシック, MS Gothic, MS ゴック", display_name: "MS Gothic", url: 'local', recommended: false},
"MS-PMincho" : {full_font_name: "MS PMincho, MS P明朝, MS Mincho, MS 明朝", display_name: "MS Mincho", url: 'local', recommended: false},
"Yu-Gothic" : {full_font_name: "Yu Gothic, YuGothic", display_name: "Yu Gothic", url: 'local', recommended: false},
"Yu-Mincho" : {full_font_name: "Yu Mincho, YuMincho", display_name: "Yu Mincho", url: 'local', recommended: false},
// GoogleFonts
"Zen-Kurenaido" : {full_font_name: "Zen Kurenaido", display_name: "Zen Kurenaido", url: 'https://fonts.googleapis.com/css?family=Zen+Kurenaido&subset=japanese', recommended: false},
"Kaisei-Opti" : {full_font_name: "Kaisei Opti", display_name: "Kaisei Opti", url: 'https://fonts.googleapis.com/css?family=Kaisei+Opti&subset=japanese', recommended: false},
"Reggae-One" : {full_font_name: "Reggae One", display_name: "Reggae One", url: 'https://fonts.googleapis.com/css?family=Reggae+One&subset=japanese', recommended: false},
"New-Tegomin" : {full_font_name: "New Tegomin", display_name: "New Tegomin", url: 'https://fonts.googleapis.com/css?family=New+Tegomin&subset=japanese', recommended: false},
"Yuji-Boku" : {full_font_name: "Yuji Boku", display_name: "Yuji Boku", url: 'https://fonts.googleapis.com/css?family=Yuji+Boku&subset=japanese', recommended: false},
"Yuji-Mai" : {full_font_name: "Yuji Mai", display_name: "Yuji Mai", url: 'https://fonts.googleapis.com/css?family=Yuji+Mai&subset=japanese', recommended: false},
"Yuji-Syuku" : {full_font_name: "Yuji Syuku", display_name: "Yuji Syuku", url: 'https://fonts.googleapis.com/css?family=Yuji+Syuku&subset=japanese', recommended: false},
"DotGothic16" : {full_font_name: "DotGothic16", display_name: "DotGothic16", url: 'https://fonts.googleapis.com/css?family=DotGothic16&subset=japanese', recommended: true},
"Hachi-Maru-Pop" : {full_font_name: "Hachi Maru Pop", display_name: "Hachi Maru Pop", url: 'https://fonts.googleapis.com/css?family=Hachi+Maru+Pop&subset=japanese', recommended: true},
"Yomogi" : {full_font_name: "Yomogi", display_name: "Yomogi", url: 'https://fonts.googleapis.com/css?family=Yomogi&subset=japanese', recommended: false},
"Potta-One" : {full_font_name: "Potta One", display_name: "Potta One", url: 'https://fonts.googleapis.com/css?family=Potta+One&subset=japanese', recommended: false},
"Dela-Gothic-One" : {full_font_name: "Dela Gothic One", display_name: "Dela Gothic One", url: 'https://fonts.googleapis.com/css?family=Dela+Gothic+One&subset=japanese', recommended: true},
"RocknRoll-One" : {full_font_name: "RocknRoll One", display_name: "RocknRoll One", url: 'https://fonts.googleapis.com/css?family=RocknRoll+One&subset=japanese', recommended: false},
"Stick" : {full_font_name: "Stick", display_name: "Stick", url: 'https://fonts.googleapis.com/css?family=Stick&subset=japanese', recommended: true},
"Yusei-Magic" : {full_font_name: "Yusei Magic", display_name: "Yusei Magic", url: 'https://fonts.googleapis.com/css?family=Yusei+Magic&subset=japanese', recommended: false},
"Kaisei-Decol" : {full_font_name: "Kaisei Decol", display_name: "Kaisei Decol", url: 'https://fonts.googleapis.com/css?family=Kaisei+Decol&subset=japanese', recommended: false},
"Kaisei-Tokumin" : {full_font_name: "Kaisei Tokumin", display_name: "Kaisei Tokumin", url: 'https://fonts.googleapis.com/css?family=Kaisei+Tokumin&subset=japanese', recommended: false},
// Other popular fonts
"ArmedBanana" : {full_font_name: "ArmedBanana", display_name: "Armed Banana", url: 'https://marciska.github.io/Jitai/ArmedBanana.css', recommended: true},
"ArmedLemon" : {full_font_name: "ArmedLemon", display_name: "Armed Lemon", url: 'local', recommended: false},
"AoyagiReisyosimo-AoyagiKouzan" : {full_font_name: "aoyagireisyosimo2, AoyagiKouzanFont2OTF", display_name: "Aoyagi Kouzan", url: 'local', recommended: false},
"Aquafont" : {full_font_name: "aquafont", display_name: "Aquafont", url: 'local', recommended: false},
"Shin-Maru-Go-Pro" : {full_font_name: "A-OTF Shin Maru Go Pro", display_name: "Shin Maru Go Pro", url: 'local', recommended: false},
"Chifont" : {full_font_name: "'chifont+', chifont", display_name: "Chifont", url: 'local', recommended: false},
"Cinecaption" : {full_font_name: "cinecaption", display_name: "Cinecaption", url: 'local', recommended: false},
"Darts" : {full_font_name: "darts font", display_name: "Darts", url: 'https://marciska.github.io/Jitai/Darts.css', recommended: false},
"EPSON-行書体M" : {full_font_name: "EPSON 行書体M", display_name: "EPSON 行書体M", url: 'local', recommended: false},
"EPSON-正楷書体M" : {full_font_name: "EPSON 正楷書体M", display_name: "EPSON 正楷書体M", url: 'local', recommended: false},
"EPSON-教科書体M" : {full_font_name: "EPSON 教科書体M", display_name: "EPSON 教科書体M", url: 'local', recommended: false},
"EPSON-太明朝体B" : {full_font_name: "EPSON 太明朝体B", display_name: "EPSON 太明朝体B", url: 'local', recommended: false},
"EPSON-太行書体B" : {full_font_name: "EPSON 太行書体B", display_name: "EPSON 太行書体B", url: 'local', recommended: false},
"EPSON-丸ゴシック体M" : {full_font_name: "EPSON 丸ゴシック体M", display_name: "EPSON 丸ゴシック体M", url: 'local', recommended: false},
"FC-Flower" : {full_font_name: "FC-Flower", display_name: "FC-Flower", url: 'https://marciska.github.io/Jitai/FCFlower.css', recommended: false},
"HakusyuKaisyoExtraBold_kk" : {full_font_name: "HakusyuKaisyoExtraBold_kk", display_name: "Hakusyu Kaisyo (Extra Bold)", url: 'local', recommended: false},
"Hosofuwafont" : {full_font_name: "Hosofuwafont", display_name: "Hoso Fuwa", url: 'https://marciska.github.io/Jitai/HosoFuwa.css', recommended: false},
"Nagayama-Kai" : {full_font_name: "nagayama_kai", display_name: "Nagayama Kai", url: 'https://marciska.github.io/Jitai/NagayamaKai.css', recommended: false},
"Pop-Rum-Cute" : {full_font_name: "PopRumCute", display_name: "Pop Rum Cute", url: 'https://marciska.github.io/Jitai/PopRumCute.css', recommended: false},
"San-Chou-Me" : {full_font_name: "santyoume-font", display_name: "San Chou Me", url: 'https://marciska.github.io/Jitai/SanChouMe.css', recommended: false},
};
// fonts that are selected by user to be shown
let font_pool_selected = [];
// bool indicating if hovering effect is flipped
let hover_flipped = false;
// bool indicating if a modifier key is being held down
let modifier_held = false;
//===================================================================
// Settings related stuff
//-------------------------------------------------------------------
function installSettingsMenu() {
wkof.Menu.insert_script_link({
name: script_id,
submenu: 'Settings',
title: script_name,
on_click: settingsOpen
});
}
function settingsPrepare(dialog) {
dialog.dialog({width:720});
}
async function settingsSave(settings) {
await wkof.Settings.save(script_id);
settingsApply(settings);
settingsClose(settings);
}
async function settingsLoad() {
const settings = await wkof.Settings.load(script_id);
settingsApply(settings);
}
function settingsClose(settings) {
// Remove all urls to fonts we don't use
for (const [fontkey, value] of Object.entries(font_pool)) {
if (!(fontkey in settings)) { continue; }
if (!settings[fontkey]) { // check if font is disabled
// if it is a webfont, uninstall webfont
if (value.url !== 'local') {
uninstallWebfont(value.full_font_name, value.url);
}
}
}
}
function settingsApply(settings) {
// clear cache of selected fonts
font_pool_selected = [];
// now refill the pool of selected fonts
for (const [fontkey, value] of Object.entries(font_pool)) {
if (!(fontkey in settings)) { continue; }
if (settings[fontkey]) { // check if font is enabled
// if it is a webfont, install webfont
if (value.url !== 'local') {
installWebfont(value.full_font_name, value.url);
// recheck if font is installed on machine
// if (!isFontInstalled(value.full_font_name)) { continue; }
} else { // check if local font is installed on machine
if (!isFontInstalled(value.full_font_name)) { continue; }
}
// put fonts in selected fonts
let frequency = settings[fontkey+'_frequency'];
if (frequency === undefined) { frequency = 1; } // if script started first time, the value might be undefined
frequency = Math.ceil(frequency);
for (let i = 0; i < frequency; i++) {
font_pool_selected.push(value.full_font_name);
}
}
}
// randomly shuffle font pool
shuffleArray(font_pool_selected);
if (setup_complete && pageRegex.test(document.URL)) {
updateRandomFont();
setflippedFontState();
}
}
function settingsOpen() {
// install webfonts, and remove non-accesible local fonts for selection
for (const [fontkey, value] of Object.entries(font_pool)) {
if (value.url !== 'local') { // install webfonts
installWebfont(value.full_font_name, value.url);
} else if (!isFontInstalled(value.full_font_name)) { // remove local fonts that are not installed for selection
delete font_pool[fontkey];
}
}
// order fonts alphabetically
const fontkeys = Object.keys(font_pool).sort((a, b) =>
font_pool[a].display_name.localeCompare(font_pool[b].display_name, undefined, {sensitivity: 'base'})
);
// prepare selection option for every font
const font_selector = Object.fromEntries(fontkeys.map(fontkey => ['BOX_'+fontkey, {
type: 'group',
label: `<span class="font_label${font_pool[fontkey].recommended ? ' font_recommended' : ''}">${font_pool[fontkey].display_name}</span>`,
content: {
sampletext: {
type: 'html',
html: `<p class="font_example" style="font-family:'${font_pool[fontkey].full_font_name}'">${example_sentence}</p>`
},
[fontkey]: {
type: 'checkbox',
label: 'Use font in '+script_name,
default: false,
},
[fontkey+'_frequency']: {
type: 'number',
label: 'Frequency',
hover_tip: 'The higher the value, the more often you see this font during review. It is affected by how many fonts you have enabled.',
default: 1,
min: 1,
step: 1,
}
}
}]));
// prepare configuration dialog
let dialog = new wkof.Settings({
script_id: script_id,
title: script_name+' Settings',
pre_open: settingsPrepare,
on_save: settingsSave,
on_close: settingsClose,
content: {
currentfont: {
type: 'group',
label: `<span class="font_label">Current Font: ${font_randomized}</span>`,
content: {
sampletext: {
type: 'html',
html: `<p class="font_example" style="font-family:'${font_randomized}'">${example_sentence}</p>`
}
}
},
legend: {
type: 'html',
html: `<div class="font_legend"><span class="font_recommended">: Recommended Font</span></div>`
},
divider: {
type: 'section',
label: `Filter Fonts (${fontkeys.length} available)`
},
...font_selector
}
});
dialog.open();
}
//===================================================================
// Main Script Functionality
//-------------------------------------------------------------------
function getDefaultFont(item) {
return getComputedStyle(item).fontFamily;
}
function isFontInstalled(font_name) {
// Approach from kirupa.com/html5/detect_whether_font_is_installed.htm - thanks!
// Will return false for the browser's default monospace font, sadly.
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
const text = "wim-—l~ツ亻".repeat(100); // Characters with widths that often vary between fonts.
context.font = "72px monospace";
const defaultWidth = context.measureText(text).width;
// Microsoft Edge raises an error when a context's font is set to a string
// containing certain special characters... so that needs to be handled.
try {
context.font = "72px " + font_name + ", monospace";
} catch (e) {
return false;
}
const testWidth = context.measureText(text).width;
return testWidth !== defaultWidth;
}
function isCanvasBlank(canvas) {
return !canvas.getContext('2d', { willReadFrequently: true })
.getImageData(0, 0, canvas.width, canvas.height).data
.some(channel => channel !== 0);
}
function canRepresentGlyphs(fontName, glyphs) {
const canvas = document.createElement('canvas');
canvas.width = 50;
canvas.height = 50;
const context = canvas.getContext("2d", { willReadFrequently: true });
context.textBaseline = 'top';
context.font = "24px " + fontName;
let result = true;
for (let i = 0; i < glyphs.length; i++) {
context.fillText(glyphs[i], 0, 0);
if (isCanvasBlank(canvas)) {
result = false;
break;
}
context.clearRect(0, 0, canvas.width, canvas.height);
}
return result;
}
function installWebfont(font_name, url) {
// If webfont already installed on local machine, don't need to reinstall
if (isFontInstalled(font_name)) { return; }
// install webfont
const link = document.querySelector(`link[href="${url}"]`);
if (!link) {
const newlink = document.createElement("link");
newlink.href = url;
newlink.rel = "stylesheet";
document.head.append(newlink);
}
}
function uninstallWebfont(font_name, url) {
const link = document.querySelector(`link[href="${url}"]`);
if (!link) {
link.remove();
}
}
function addPreconnectLinks() {
// add preconnect links to GoogleFonts servers
let googleApiLink = document.querySelector(`link[href="https://fonts.googleapis.com"]`);
if (!googleApiLink) {
googleApiLink = document.createElement("link");
googleApiLink.rel = "preconnect";
googleApiLink.href = "https://fonts.googleapis.com";
document.head.append(googleApiLink);
}
let gstaticLink = document.querySelector(`link[href="https://fonts.gstatic.com"]`);
if (!gstaticLink) {
gstaticLink = document.createElement("link");
gstaticLink.rel = "preconnect";
gstaticLink.href = "https://fonts.gstatic.com";
gstaticLink.crossOrigin = true;
document.head.append(gstaticLink);
}
}
function shuffleArray(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
}
function updateRandomFont() {
// choose new random font
const glyphs = item_element.innerText;
if (font_pool_selected.length === 0) {
console.log(script_name+': empty font pool!');
font_randomized = font_default;
} else {
do {
font_randomized = font_pool_selected[Math.floor(Math.random() * font_pool_selected.length)];
} while (!canRepresentGlyphs(font_randomized, glyphs));
}
style_element.innerHTML = style_element.innerHTML.replace(/(--font-family-japanese:).*;([\s\S]*?--font-family-japanese-hover:).*;/,`$1 ${font_randomized};$2 ${font_default};`);
}
function setflippedFontState() {
const flipped = hover_flipped ? !modifier_held : modifier_held;
item_element.classList.toggle('flipped', flipped);
}
function insertStyle() {
// insert CSS
if ((style_element = document.getElementById(`${script_id}-style`)) != null) return;
style_element = document.createElement('style');
style_element.setAttribute('id', `${script_id}-style`);
style_element.innerHTML = `
.font_label {
font-size: 1.2em;
display: flex;
align-items: center;
}
.font_legend {
text-align: center;
margin: 15px !important;
}
.font_example {
margin: 5px 10px 10px 10px !important;
font-size: 1.6em;
line-height: 1.1em;
}
.font_recommended::before {
content: '⭐️';
font-size: 1.4em;
}
.character-header__characters {
--font-family-japanese: ;
--font-family-japanese-hover: ;
font-family: var(--font-family-japanese);
}
.character-header__characters:hover { font-family: var(--font-family-japanese-hover); }
.character-header__characters.flipped { font-family: var(--font-family-japanese-hover); }
.character-header__characters.flipped:hover { font-family: var(--font-family-japanese); }
`;
document.head.appendChild(style_element);
}
function cacheDefaultElementStyles() {
if (font_default !== undefined && font_randomized !== undefined) return;
item_element = document.getElementsByClassName("character-header__characters")[0];
if (!item_element) return;
font_default = getDefaultFont(item_element);
font_randomized = font_default;
}
function onKeyDown(event) {
if (event.repeat) return;
switch (event.key) {
// on holding ctrl and shift, swap the shown font
case 'Control':
case 'Shift':
if (!event.ctrlKey || !event.shiftKey) return;
modifier_held = true;
setflippedFontState();
break;
// on alt+j, update to a new random font
case 'j':
if (!event.altKey) return;
updateRandomFont();
setflippedFontState();
break;
}
}
function onKeyUp(event) {
if (event.repeat) return;
switch (event.key) {
case 'Control':
case 'Shift':
modifier_held = false;
setflippedFontState();
break;
}
}
function onLostFocus() {
modifier_held = false;
setflippedFontState();
}
function onDidAnswerQuestion() {
hover_flipped = true;
setflippedFontState();
}
function onDidUnanswerQuestion() {
hover_flipped = false;
updateRandomFont();
setflippedFontState();
}
function onWillShowNextQuestion() {
hover_flipped = false;
if (setup_complete) {
updateRandomFont();
setflippedFontState();
}
}
function registerJitaiEvents() {
// on answer submission, invert hovering event
// - normal : default font
// - hovering: randomized font
global.addEventListener("didAnswerQuestion", onDidAnswerQuestion, listenerOptions);
// on advancing to next item question, randomize font again
global.addEventListener("willShowNextQuestion", onWillShowNextQuestion, listenerOptions);
// on reverting an answer by DoubleCheckScript, reroll random font and fix inverting of hovering
global.addEventListener("didUnanswerQuestion", onDidUnanswerQuestion, listenerOptions);
// add event to show regular font
// add event to reroll randomized font
global.addEventListener("keydown", onKeyDown, listenerOptions);
global.addEventListener("keyup", onKeyUp, listenerOptions);
// when page loses focus, revert any temporary modifications
global.addEventListener("blur", onLostFocus, listenerOptions);
}
function unregisterJitaiEvents() {
global.removeEventListener("didAnswerQuestion", onDidAnswerQuestion, listenerOptions);
global.removeEventListener("willShowNextQuestion", onWillShowNextQuestion, listenerOptions);
global.removeEventListener("didUnanswerQuestion", onDidUnanswerQuestion, listenerOptions);
global.removeEventListener("keydown", onKeyDown, listenerOptions);
global.removeEventListener("keyup", onKeyUp, listenerOptions);
global.removeEventListener("blur", onLostFocus, listenerOptions);
}
//===================================================================
// Script Startup
//-------------------------------------------------------------------
function startup() {
// initialization of the Wanikani Open Framework
if (!wkof) {
if (confirm(script_name+' requires Wanikani Open Framework.\nDo you want to be forwarded to the installation instructions?')) {
global.location.href = 'https://community.wanikani.com/t/instructions-installing-wanikani-open-framework/28549';
}
return;
}
const wkof_modules = 'Settings';
wkof.include(wkof_modules);
wkof.ready(wkof_modules)
.then(addPreconnectLinks)
.then(settingsLoad)
.then(() => {setup_complete = true;});
}
function onReviewsPage() {
const wkof_modules = 'Menu';
wkof.include(wkof_modules);
wkof.ready(wkof_modules)
.then(cacheDefaultElementStyles)
.then(insertStyle)
.then(registerJitaiEvents)
.then(installSettingsMenu);
}
function onNonReviewsPage() {
unregisterJitaiEvents();
}
function onLoad(event) {
if (pageRegex.test(event.detail.url))
onReviewsPage();
else
onNonReviewsPage();
}
startup();
const loadedUrl = (window.Turbo?.session.history.pageLoaded ? window.Turbo.session.history.location : (document.readyState === "complete" ? document.URL : null));
if (loadedUrl !== null) {
setup_complete = true;
onLoad({detail:{url:loadedUrl},target:document.documentElement});
}
document.documentElement.addEventListener('turbo:load', (event) => {setTimeout(onLoad(event), 0);});
})(window);