// ==UserScript==
// @name WaniKani Quick Type
// @namespace wkquicktype
// @description Speed up your your lessons: Check and accept answers for meanings after a minimal amount of typed characters.
// @match https://www.wanikani.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=wanikani.com
// @version 1.0.9
// @author polysoda
// @license MIT; http://opensource.org/licenses/MIT
// @run-at document-end
// @grant none
// @require https://cdnjs.cloudflare.com/ajax/libs/toastify-js/1.6.1/toastify.min.js
// ==/UserScript==
/* jshint esversion: 8 */
(async function (wkof) {
'use strict';
if (!wkof) {
alert("WK Autocomplete requires Wanikani Open Framework." +
"You will now be forwarded to installation instructions.");
window.location.href = "https://community.wanikani.com/t/" +
"instructions-installing-wanikani-open-framework/28549";
return;
} else {
wkof.include('Menu, Settings');
wkof.ready('Menu, Settings')
.then(load_settings)
.then(install_menu)
}
const scriptId = "wanikaniQuickType";
const wkofScriptId = "wkofs_" + scriptId;
let inputClass = ".quiz-input__input";
const enterEvent = new KeyboardEvent("keydown", {
key: "Enter",
code: "Enter",
which: 13,
keyCode: 13,
bubbles: true,
cancelable: true,
});
let id;
let lessonType = null;
let essentialMeanings;
// settings vars
let maxMeaningsCount = 8;
let maxCharCount = 3;
let enableToast = true;
let toastLocation = "Top";
let useSpaceEscape = true;
let countdownCreated = false;
let countdownTimer = null;
// load assets (toastify css)
const toastCss = 'https://cdn.jsdelivr.net/npm/toastify-js/src/toastify.min.css';
let promises = [];
promises[0] = wkof.load_css(toastCss, true /* use_cache */);
Promise.all(promises);
window.addEventListener("willShowNextQuestion", (e) => {
//console.log("Event: willShowNextQuestion");
if (countdownTimer !== null){
countdownTimer.style.display = 'none';
}
setTimeout(main, 1000);
});
function main (){
const inputbutton = document.querySelector(".quiz-input__submit-button");
let inputElement = document.querySelector(inputClass);
setEssentialMeanings();
if (inputElement === null) return;
createCountdown();
inputElement.addEventListener('keydown', event => {
setTimeout(function () {
let inputValue = inputElement.value;
if (!useSpaceEscape){
inputValue = inputValue.replace(/^\s?/, '');
}
const inputCharCount = inputValue.length;
if (lessonType == "reading") return;
if (inputCharCount >= maxCharCount) {
event.preventDefault();
const essentialMeaning = getMatchingMeaning(inputValue, essentialMeanings);
if (essentialMeaning !== null) {
inputElement.removeEventListener('keydown');
inputElement.value = essentialMeaning;
if(enableToast && inputCharCount == maxCharCount && essentialMeaning !== maxCharCount){
showToast(String("👍 " + inputValue + " → " + essentialMeaning));
}
inputbutton.click();
}
}
}, 100);
});
}
function createCountdown() {
if(countdownCreated) return;
countdownCreated = true;
const container = document.querySelector('.quiz-input__input-container');
const input = container.querySelector('.quiz-input__input');
// Create countdown timer element
countdownTimer = document.createElement('div');
countdownTimer.className = 'countdown-timer';
countdownTimer.style.display = 'none'; // Initially hidden
// Append countdown timer to container
container.appendChild(countdownTimer);
// Get the computed styles of the input field
const inputStyle = window.getComputedStyle(input);
const inputHeight = input.offsetHeight;
const inputPaddingTop = parseFloat(inputStyle.paddingTop) + parseFloat(inputStyle.paddingBottom);
const inputPaddingLeft = parseFloat(inputStyle.paddingLeft);
// Calculate the top and left positions based on input's height and padding
const containerPaddingTop = parseFloat(window.getComputedStyle(container).paddingTop);
const topPosition = containerPaddingTop + input.offsetTop;
// Set a variable offset for the left position
const leftOffset = 10; // You can change this value as needed
const leftPosition = leftOffset + inputPaddingLeft + 'px';
// Apply CSS styles dynamically via JavaScript
Object.assign(countdownTimer.style, {
position: 'absolute',
left: leftPosition,
top: `${topPosition}px`,
width: '40px',
height: `${inputHeight - inputPaddingTop}px`,
lineHeight: `${inputHeight - inputPaddingTop}px`,
});
// Initialize countdown
let countDown = 3;
let timeLeft = countDown; // 3 seconds countdown
// Function to update countdown
function updateCountdown() {
const typedCharacters = input.value.length;
// Show countdown only after the first character is entered
console.log(lessonType);
if (input.value[0] !== ' ' && lessonType !== 'reading') {
countdownTimer.style.display = 'flex'; // Show countdown timer
const remainingCharacters = Math.max(typedCharacters, 0); // Subtract 1 to account for the first character
timeLeft = Math.max(3 - remainingCharacters, 0);
countdownTimer.textContent = timeLeft;
// Show shaking animation with red background if more than 3 characters
/*
if (typedCharacters >= countDown) {
countdownTimer.classList.add('shake');
countdownTimer.style.backgroundColor = 'red';
} else {
countdownTimer.classList.remove('shake');
countdownTimer.style.backgroundColor = 'green';
}
*/
} else {
countdownTimer.style.display = 'none'; // Hide countdown timer
}
}
// Add event listeners for typing and keyup
input.addEventListener('input', updateCountdown);
input.addEventListener('keydown', () => {
countdownTimer.classList.add('scale-down');
});
input.addEventListener('keyup', () => {
countdownTimer.classList.remove('scale-down');
countdownTimer.classList.add('scale-up');
setTimeout(() => countdownTimer.classList.remove('scale-up'), 200); // Remove bounce effect after animation
});
}
function extractQuizDetails() {
// Step 1: Extract `data-subject-id`, `category`, and `questionType` from the label element
const label = document.querySelector('label[data-subject-id]');
const subjectId = label ? label.getAttribute('data-subject-id') : null;
if (!subjectId) {
return { subjectId: null, category: null, questionType: null, meanings: null };
}
const categoryElement = label.querySelector('span[data-quiz-input-target="category"]');
const questionTypeElement = label.querySelector('span[data-quiz-input-target="questionType"]');
const category = categoryElement ? categoryElement.textContent : null;
const questionType = questionTypeElement ? questionTypeElement.textContent : null;
// Step 2: Parse JSON data from the script tag
const script = document.querySelector('script[data-quiz-queue-target="subjects"]');
let subjects = [];
if (script) {
try {
subjects = JSON.parse(script.textContent);
} catch (e) {
console.error('Error parsing JSON from script tag:', e);
}
}
// Step 3: Find the subject object matching the extracted `data-subject-id`
const subject = subjects.find(subject => subject.id === parseInt(subjectId));
// Step 4: Extract the meanings array from the subject object
const meanings = subject ? subject.meanings : null;
return {
subjectId: subjectId,
category: category,
questionType: questionType,
meanings: meanings
};
}
function setEssentialMeanings() {
let details = extractQuizDetails()
id = details.subjectId;
const synonyms = getSynonymsById(id);
const meanings = details.meanings;
lessonType = details.questionType;
essentialMeanings = combineArrays(synonyms, meanings, maxMeaningsCount);
}
function getMatchingMeaning(prefix, stringArray) {
if (stringArray == null || prefix == null) {
return null;
}
function normalizeUmlauts(str) {
return str
.replace(/ä/g, 'ae')
.replace(/ü/g, 'ue')
.replace(/ö/g, 'oe')
.replace(/ß/g, 'ss')
.toLowerCase();
}
const normalizedPrefix = normalizeUmlauts(prefix.toLowerCase());
for (let item of stringArray) {
const normalizedItem = normalizeUmlauts(item.toLowerCase());
if (normalizedItem.startsWith(normalizedPrefix)) {
return item;
}
}
return null;
}
function getSynonymsById(id) {
const scriptTag = document.querySelector('script[data-quiz-user-synonyms-target="synonyms"]');
if (scriptTag) {
const synonymsData = JSON.parse(scriptTag.textContent);
return synonymsData[id] || [];
} else {
console.error('Script tag with the specified type and data attribute was not found.');
return [];
}
}
function combineArrays(array1, array2, x) {
const firstPart = array1.slice(0, x);
const secondPart = array2.slice(0, x);
const combinedArray = [...firstPart, ...secondPart];
return combinedArray;
}
function showToast(text) {
toastLocation = toastLocation.toLowerCase();
Toastify({
text: text,
duration: 2000,
close: false,
gravity: toastLocation, // `top` or `bottom`
position: "center", // `left`, `center` or `right`
stopOnFocus: true, // Prevents dismissing of toast on hover
style: {
background: "linear-gradient(339deg, rgba(0,185,155,1) 0%, rgba(23,218,157,1) 100%)",
fontSize: "12px",
fontWeight: "bold",
borderRadius: "8px",
color: "#fff"
}
}).showToast();
}
appendStyleElem();
function appendStyleElem() {
const styleElem = document.createElement("style");
styleElem.innerHTML = `
.demo-style {
}
`;
document.head.append(styleElem);
}
// ––––––– Settings ––––––– //
// This function is called when the Settings module is ready to use.
function load_settings() {
let defaults = {
maxMeaningsCount: 8,
maxCharCount: 3
};
wkof.Settings.load('wanikaniQuickType', defaults)
.then(update_settings);
}
// Add settings menu to the menu
function install_menu() {
let config = {
name: 'wanikaniQuickType',
submenu: 'Settings',
title: 'Quick Type',
on_click: open_settings
};
wkof.Menu.insert_script_link(config);
}
// Define settings menu layout
function open_settings(items) {
let config = {
script_id: scriptId,
title: 'Quick Type',
on_save: update_settings,
on_close: update_settings,
content: {
maxCharCount: {
type: 'number',
label: "Character count",
hover_tip: "The amount of typed characters after which the check of the meanings is started.",
default: 3,
min: 1,
max: 10
},
maxMeaningsCount: {
type: 'number',
label: "Meanings count",
hover_tip: "The count of synonyms and meanings items to include in check of the meanings",
default: 8,
min: 1,
max: 8
},
useSpaceEscape: {
type: 'checkbox',
label: "Disable with leading space",
hover_tip: "Type a space charakter at the beginning to temporarily disable Quick Type",
default: true
},
enableToast: {
type: 'checkbox',
label: "Enable notification",
hover_tip: "A quick info will be shown with the matched meaning based on the short input.",
default: true
},
toastLocation: {
type: 'dropdown',
label: "Notifiocation location",
hover_tip: "The location of the info toast.",
default: "Top",
content: {
top: "Top",
bottom: "Bottom",
}
}
}
}
let dialog = new wkof.Settings(config);
dialog.open();
}
function update_settings(settings) {
maxMeaningsCount = settings.maxMeaningsCount;
maxCharCount = settings.maxCharCount;
enableToast = settings.enableToast;
toastLocation = settings.toastLocation;
useSpaceEscape = settings.useSpaceEscape;
wkof.Settings.save("wanikaniQuickType");
}
const dialog = `
#wkof_ds div.ui-dialog[aria-describedby="${wkofScriptId}"]
`;
const css = `
/* quick type styles */
.countdown-timer {
background-color: green;
color: white;
text-align: center;
border-radius: 10px;
font-size: 1.2em;
display: flex;
justify-content: center;
align-items: center;
position: relative;
z-index: 1;
transition: transform 0.2s ease; /* Smooth transition for scaling */
}
.countdown-timer::before {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
border: 2px dashed white; /* White dashed border */
border-radius: 10px;
z-index: 2;
}
@keyframes scaleDown {
0% { transform: scale(1); }
100% { transform: scale(0.9); }
}
@keyframes scaleUp {
0% { transform: scale(0.9); }
75% { transform: scale(1.1); }
100% { transform: scale(1); }
}
.scale-down {
animation: scaleDown 0.07s forwards;
}
.scale-up {
animation: scaleUp 0.14s ease;
}
@keyframes shake {
0% { transform: translateX(0); }
25% { transform: translateX(-5px); }
50% { transform: translateX(5px); }
75% { transform: translateX(-5px); }
100% { transform: translateX(0); }
}
.shake {
animation: shake 0.5s infinite;
}
${dialog}{
background-color: #fff;
background-image: none!important;
border-radius: 12px!important;
padding: 8px;
width: 456px;
}
${dialog} .ui-widget-header {
border: none;
background: none;
color: #222222;
}
${dialog} .ui-widget-header .ui-dialog-title{
font-size: 24px;
line-height: 32px;
padding: 8px;
}
${dialog} .left{
width: 196px;
}
${dialog} form label {
text-align: left;
opacity: .72;
}
${dialog} form .row {
margin-bottom: 8px;
}
${dialog} input[type="checkbox"] {
width: 24px;
margin-left: 0;
}
${dialog} .ui-dialog-titlebar {
margin-bottom: 8px;
}
${dialog} button.ui-dialog-titlebar-close,
${dialog} button.ui-dialog-titlebar-close .ui-button-icon{
padding: 0;
background: none;
border: none;
text-indent: 0;
width: 40px;
height: 40px;
border-radius: 40px;
}
${dialog} button.ui-dialog-titlebar-close{
position: absolute;
top: 8px;
right: -2px;
}
${dialog} button.ui-dialog-titlebar-close .ui-button-icon{
position: absolute;
top: 10px;
left: 0;
margin-top: 0;
margin-left: 0;
overflow: visible;
}
${dialog} button.ui-dialog-titlebar-close:hover{
background: #f1f1f1;
}
${dialog} button.ui-dialog-titlebar-close {
text-indent: -9999px;
}
${dialog} button.ui-dialog-titlebar-close .ui-button-icon:after{
font-size: 20px;
line-height: 20px;
content: "✕";
}
${dialog} button.ui-dialog-titlebar-close .ui-button-icon-space{
display: none;
}
${dialog} .ui-dialog-buttonset {
order: revert;
display: flex;
}
${dialog} .ui-dialog-buttonset button{
text-shadow: none;
padding: 8px 28px;
font-size: 14px;
display: flex;
border-radius: 8px;
}
${dialog} .ui-dialog-buttonset button:first-child{
color: #fff;
border: var(--color-radical);
background: var(--color-radical);
padding: 8px 32px;
}
${dialog} .ui-dialog-buttonset button:after {
content: ' ';
position: absolute;
border-radius: 8px;
width: 100%;
height:100%;
top:0;
left:0;
background:rgba(0,0,0,0.04);
opacity: 0;
transition: all .2s;
-webkit-transition: all .2s;
}
${dialog} .ui-dialog-buttonset button:hover:after {
opacity: 1;
}
${dialog} .ui-dialog-buttonset button:first-child:hover:after {
background:rgba(0,0,0,0.06);
}
${dialog} .ui-dialog-buttonset button:nth-child(2){
border: 1px solid #ddd;
background: none;
color: #333;
order: -1;
margin-right:8px;
}
${dialog} .ui-dialog-buttonpane {
border-top: none;
padding: 16px 0 8px 0;
}
`;
function appendCssToHead(cssString) {
const styleElement = document.createElement('style');
styleElement.textContent = cssString;
const headElement = document.head || document.getElementsByTagName('head')[0];
headElement.appendChild(styleElement);
}
appendCssToHead(css);
})(window.wkof);