WaniKani Keyboard Focus

Keeps keyboard focus on the lesson input field

// ==UserScript==
// @name WaniKani Keyboard Focus
// @author Brian Frichette
// @description Keeps keyboard focus on the lesson input field
// @license MIT
// @match https://www.wanikani.com/subjects/*
// @match https://www.wanikani.com/subject-lessons/*
// @run-at document-idle
// @supportURL https://github.com/bfricka/wk-keyboard-focus
// @version 1.2.1
// @namespace bfricka
// ==/UserScript==
;(() => {
	'use strict'
	let debug = false
	const SUBTREE = { childList: true, subtree: true }
	const isElement = (node) => node?.nodeType === 1
	const hasClass = (node, cls) => isElement(node) && node.classList.contains(cls)
	const noop = () => {}
	const getAttr = (el, attr) => el.attributes.getNamedItem(attr)
	const getAttrVal = (el, attr) => getAttr(el, attr)?.value
	const log = (...v) => {
		if (debug) console.log('[WK-KB-FOCUS]', ...v)
	}
	let State
	;(function (State) {
		State[(State['MAIN'] = 0)] = 'MAIN'
		State[(State['MODAL'] = 1)] = 'MODAL'
		State[(State['NOTES'] = 2)] = 'NOTES'
		State[(State['POINTER'] = 3)] = 'POINTER'
		State[(State['LESSON_MODAL'] = 4)] = 'LESSON_MODAL'
		State[(State['WK_OPEN_FRAMEWORK'] = 5)] = 'WK_OPEN_FRAMEWORK'
	})(State || (State = {}))
	const state = (() => {
		const enabledStates = Object.keys(State).map(() => true)
		return {
			disable(state, disable = true) {
				log('disabling', State[state])
				enabledStates[state] = !disable
			},
			enable(state, enabled = true) {
				log('enabling', State[state])
				enabledStates[state] = enabled
			},
			isEnabled: (state) => {
				if (state != null) return enabledStates[state]
				return enabledStates.every((v) => v)
			},
		}
	})()
	class WKObserver {
		#cb = noop
		#cleanup = noop
		#isRunning = false
		#observer = new MutationObserver((items) => {
			for (const item of items) {
				if (this.#cb(item) === false) return
			}
		})
		get running() {
			return this.#isRunning
		}
		dispose = () => {
			this.#cb = noop
			this.#observer.disconnect()
			this.#isRunning = false
			this.#cleanup()
			return this
		}
		init = ($el, cb, opts, cleanup = noop) => {
			this.#cleanup = cleanup
			this.dispose()
			this.#cb = cb
			this.#observer.observe($el, opts)
			this.#isRunning = true
			return this
		}
	}
	const Observers = {
		info: new WKObserver(),
		meaningNotes: new WKObserver(),
		modal: new WKObserver(),
		modalInput: new WKObserver(),
		quizQueue: new WKObserver(),
		readingNotes: new WKObserver(),
		turbo: new WKObserver(),
		wkofDiaglogBg: new WKObserver(),
	}
	const disposeAll = () => {
		log('Disposing all observers and stopping')
		Object.values(Observers).forEach((o) => o.dispose())
	}
	const createEventDelegateListener = (matcher, listener) => (ev) => {
		if (matcher(ev)) listener(ev)
	}
	const clsDelegateListener = (className, listener) =>
		createEventDelegateListener((ev) => {
			let currentNode = ev.target
			while (currentNode) {
				if (currentNode.classList.contains(className)) return true
				currentNode = currentNode.parentElement
			}
			return false
		}, listener)
	const BodyDelegate = (() => {
		const bodyListenerMap = {}
		const listenerMapCapture = {}
		const listenerMap = {}
		const dispose = () => {
			for (const [k, listener] of Object.entries(bodyListenerMap)) {
				const [type, captureStr] = k.split('|')
				const capture = captureStr === 'true'
				document.body.removeEventListener(type, listener, { capture })
			}
		}
		const on = (type, listener, capture = false) => {
			const lm = capture ? listenerMapCapture : listenerMap
			let listeners = lm[type]
			if (!listeners) {
				listeners = lm[type] = new Set()
				const bodyListener = (bodyListenerMap[`${type}|${capture}`] = (ev) => {
					if (state.isEnabled(State.MAIN)) listeners.forEach((l) => l(ev))
				})
				document.body.addEventListener(type, bodyListener, { capture })
			}
			listeners.add(listener)
		}
		const off = (type, listener, capture = false) => {
			const listeners = (capture ? listenerMapCapture : listenerMap)[type]
			if (!listeners) return
			listeners.delete(listener)
			if (!listeners.size) {
				const bodyListener = bodyListenerMap[`${type}|${capture}`]
				if (bodyListener) {
					document.body.removeEventListener(type, bodyListener, { capture })
				}
			}
		}
		return { dispose, on, off }
	})()
	// Relative position container
	const addGlassesBtn = (() => {
		const $btn = document.createElement('button')
		$btn.type = 'button'
		$btn.classList.add('wk-focus__btn')
		$btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path class="wk-focus__glasses-icon" d="M224 232a32 32 0 0164 0M448 200h16M64 200H48M64 200c0 96 16 128 80 128s80-32 80-128c0 0-16-16-80-16s-80 16-80 16zM448 200c0 96-16 128-80 128s-80-32-80-128c0 0 16-16 80-16s80 16 80 16z" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="32"/></svg>`
		const $styles = document.createElement('style')
		$styles.textContent = `
    .wk-focus__btn {
      position: absolute;
      height: 1.5rem;
      width: 1.5rem;
      top: 50%;
      left: 1.5rem;
      transform: translateY(-50%);
      appearance: none;
      background: none;
      cursor: pointer;
    }

    .wk-focus__btn.wk-focus__fade {
      opacity: 0.5;
    }

    .wk-focus__glasses-icon {
      stroke: #111;
    }

    .quiz-input__input-container[correct=true] .wk-focus__glasses-icon,
		.quiz-input__input-container[correct=false] .wk-focus__glasses-icon {
      stroke: #fff;
    }`
		let handler = noop
		const dispose = () => {
			$btn.removeEventListener('click', handler)
			$btn.remove()
			$styles.remove()
			handler = noop
		}
		const toggleEnabled = (overrideEnable) => {
			const shouldEnable = overrideEnable ?? !state.isEnabled(State.MAIN)
			if (shouldEnable) state.enable(State.MAIN)
			else state.disable(State.MAIN)
			$btn.classList.toggle('wk-focus__fade', !shouldEnable)
		}
		return ($inputOffsetParent) => {
			dispose()
			$inputOffsetParent.append($btn)
			document.body.append($styles)
			let clicks = 0
			let timeoutId = -1
			handler = (ev) => {
				clicks++
				ev.preventDefault()
				toggleEnabled()
				inputManager.focus()
				clearTimeout(timeoutId)
				timeoutId = setTimeout(() => {
					clicks = 0
				}, 500)
				if (clicks >= 4) {
					clicks = 0
					debug = !debug
					clearTimeout(timeoutId)
					toggleEnabled(true)
					console.log('[WK-KB-FOCUS]', 'Toggling debug', debug)
				}
			}
			$btn.addEventListener('click', handler)
		}
	})()
	const initModalObserver = ($modal) => {
		log('Initializing modal observer')
		const findAutofocusInput = () => [...$modal.querySelectorAll('input')].find(($i) => $i.autofocus)
		const foundAndFocused = () => {
			const $autofocusInput = findAutofocusInput()
			if ($autofocusInput) {
				$autofocusInput.focus()
				Observers.modalInput.dispose()
				log('Found modal autofocus input', $autofocusInput)
				return true
			}
			log('Could not find modal autofocus input')
			return false
		}
		const initInputObserverAndFocus = () => {
			if (Observers.modalInput.running || foundAndFocused()) return
			Observers.modalInput.init(
				$modal,
				(item) => {
					if (item.addedNodes.length) return !foundAndFocused()
				},
				SUBTREE,
			)
		}
		Observers.modalInput.dispose()
		Observers.modal.init(
			$modal,
			(item) => {
				const $el = item.target
				if (!(isElement($el) && item.attributeName === 'hidden')) return
				if ($el.hidden) {
					Observers.modalInput.dispose()
					state.enable(State.MODAL)
					inputManager.focus()
					log('Modal hidden. Re-setting focus')
					return
				}
				log('Modal visible. Trying to find autofocus input.')
				state.disable(State.MODAL)
				initInputObserverAndFocus()
			},
			{ attributeFilter: ['hidden'] },
		)
	}
	const initInfoObserver = ($subjectInfo) => {
		log('Initializing subject info observer')
		let lastSrc = ''
		const findNoteForm = (nodes) =>
			[...nodes].find((node) => isElement(node) && node.classList.contains('user-note__form'))
		const initNoteObserver = ($notesEl, observer) => {
			if (!$notesEl) {
				observer.dispose()
				return
			}
			if (observer.running) return
			observer.init(
				$notesEl,
				({ addedNodes, removedNodes }) => {
					const $addedNoteForm = findNoteForm(addedNodes)
					if ($addedNoteForm) {
						state.disable(State.NOTES)
						$addedNoteForm.querySelector('textarea.user-note__input')?.focus()
						return false
					}
					if (findNoteForm(removedNodes)) {
						state.enable(State.NOTES)
						inputManager.focus()
						return false
					}
				},
				SUBTREE,
			)
		}
		Observers.meaningNotes.dispose()
		Observers.readingNotes.dispose()
		Observers.info.init(
			$subjectInfo,
			({ attributeName, target }) => {
				if (!attributeName || !isElement(target)) return
				const rawValue = getAttrVal(target, attributeName)
				const isLoaded = rawValue === 'true'
				if (isLoaded) {
					initNoteObserver(document.getElementById('user_meaning_note'), Observers.meaningNotes)
					initNoteObserver(document.getElementById('user_reading_note'), Observers.readingNotes)
				} else {
					Observers.meaningNotes.dispose()
					Observers.readingNotes.dispose()
				}
			},
			{ attributeFilter: ['data-loaded'] },
		)
	}
	const initQuizQueueObserver = ($quizQueue) => {
		log('Initializing quiz queue observer')
		const findLessonModal = (nodes) =>
			[...nodes].find((node) => isElement(node) && node.classList.contains('lesson-modal'))
		Observers.quizQueue.init(
			$quizQueue,
			(item) => {
				const $addedLessonModal = findLessonModal(item.addedNodes)
				if ($addedLessonModal) {
					const $nextBtn = $addedLessonModal.querySelector(
						'.lesson-modal__button[data-default="true"] > a',
					)
					state.disable(State.LESSON_MODAL)
					$nextBtn?.focus()
					return false
				}
				const $removedLessonModal = findLessonModal(item.removedNodes)
				if ($removedLessonModal) {
					$quizQueue.focus()
					state.enable(State.LESSON_MODAL)
				}
			},
			SUBTREE,
			() => {
				// We can be disposed before the removal observer fires, so we reset on dispose
				state.enable(State.LESSON_MODAL)
			},
		)
	}
	const inputManager = (() => {
		let $input = null
		let $inputOffsetParent = null
		let $scrollContainer = null
		let focusInput = null
		const getScroll = () => {
			if ($scrollContainer) return $scrollContainer
			$scrollContainer = document.querySelector('[data-controller=scrollable]')
			return $scrollContainer || document.body
		}
		const dispose = () => {
			BodyDelegate.dispose()
			$input = null
			$inputOffsetParent = null
			$scrollContainer = null
			focusInput = null
		}
		const init = ($initInput) => {
			log('Initializing input manager')
			dispose()
			$input = $initInput
			$inputOffsetParent = $initInput.offsetParent
			focusInput = () => {
				const scrollPos = getScroll().scrollTop
				$input?.focus()
				getScroll().scrollTop = scrollPos
			}
			addGlassesBtn($inputOffsetParent)
			const disable = () => state.disable(State.POINTER)
			const enableTimeout = () =>
				setTimeout(() => {
					log('Re-enabling focus after pointer')
					state.enable(State.POINTER)
					if (state.isEnabled()) focusInput?.()
				})
			;[
				'subject-collocations__pattern-name',
				'subject-section__toggle',
				'user-synonyms__buttons',
			].forEach((cls) => {
				// Disable on pointer down so we can blur
				BodyDelegate.on('pointerdown', clsDelegateListener(cls, disable), true)
				// Re-enable + focus on pointer up
				BodyDelegate.on('pointercancel', clsDelegateListener(cls, enableTimeout), true)
				BodyDelegate.on('pointerup', clsDelegateListener(cls, enableTimeout), true)
			})
			$input.addEventListener('blur', () => {
				const isEnabled = state.isEnabled()
				log('Input blur', isEnabled)
				if (isEnabled) focusInput?.()
			})
			focusInput()
		}
		const update = ($nextInput) => {
			if ($nextInput) {
				if (!$input) init($nextInput)
				return
			}
			if ($input) dispose()
		}
		return {
			focus() {
				focusInput?.()
			},
			get running() {
				return Boolean(focusInput)
			},
			update,
		}
	})()
	const initTurboObserver = ($turboBody) => {
		disposeAll()
		const updateUserInput = () => {
			// Main input element for answers
			inputManager.update(document.getElementById('user-response'))
		}
		const updateQuizQueue = () => {
			// Contains lesson modal ("Next batch, please!")
			const $quizQueue = document.getElementById('quiz-queue')
			if ($quizQueue) {
				Observers.quizQueue.running || initQuizQueueObserver($quizQueue)
			} else if (Observers.quizQueue.running) {
				state.enable(State.LESSON_MODAL)
				Observers.quizQueue.dispose()
			}
		}
		const updateSubjectInfo = () => {
			// Main "additional information" turbo module
			const $subjectInfo = document.getElementById('subject-info')
			if ($subjectInfo) {
				Observers.info.running || initInfoObserver($subjectInfo)
			} else if (Observers.info.running) {
				state.enable(State.NOTES)
				Observers.info.dispose()
			}
		}
		const isEgg = (node) => isElement(node) && node.id === 'egg_timer'
		Observers.turbo.init(
			$turboBody,
			(item) => {
				// Superfluous egg timer runs every second
				if (isEgg(item.target)) return
				updateUserInput()
				updateQuizQueue()
				updateSubjectInfo()
				for (const node of item.addedNodes) {
					if (hasClass(node, 'wk-modal')) {
						initModalObserver(node)
						break
					}
				}
				for (const node of item.removedNodes) {
					if (hasClass(node, 'wk-modal')) {
						state.enable(State.MODAL)
						Observers.modal.dispose()
					}
				}
			},
			SUBTREE,
		)
		updateUserInput()
		updateQuizQueue()
		updateSubjectInfo()
		const $modal = document.querySelector('.wk-modal')
		$modal && initModalObserver($modal)
	}
	const initWkofDialogObserver = ($wkofDialogBg) => {
		log('Init WaniKani Open Framework backdrop observer')
		const DIALOG_REF_COUNT_ATTR = 'refcnt'
		const tryFocusDialogInput = ($el) => {
			const dialogTextInputs = $el.nextElementSibling?.querySelectorAll('input[type="text"]')
			if (dialogTextInputs) {
				for (const $input of dialogTextInputs) {
					if ($input.autofocus) {
						$input.focus()
						return
					}
				}
				// If we don't find an autofocus input, try to focus the first one
				dialogTextInputs[0]?.focus()
			}
		}
		const handleWkofDialogChange = ($el) => {
			const dialogRefCount = Number.parseInt(getAttrVal($el, DIALOG_REF_COUNT_ATTR) || '0')
			const isEnabled = state.isEnabled(State.WK_OPEN_FRAMEWORK)
			if (dialogRefCount && isEnabled) {
				state.disable(State.WK_OPEN_FRAMEWORK)
				tryFocusDialogInput($el)
			}
			if (!dialogRefCount && !isEnabled) {
				state.enable(State.WK_OPEN_FRAMEWORK)
				inputManager.focus()
			}
		}
		Observers.wkofDiaglogBg.init(
			$wkofDialogBg,
			({ attributeName, target }) => {
				if (!(attributeName && isElement(target))) return
				handleWkofDialogChange(target)
			},
			{ attributeFilter: ['refcnt'] },
		)
		handleWkofDialogChange($wkofDialogBg)
	}
	const wkFocusRunner = (() => {
		let $lastTurboBody = null
		let $lastWkofDialogBg = null
		return ($turboBody, $wkofDialogBg) => {
			if ($turboBody && (!Observers.turbo.running || $lastTurboBody !== $turboBody))
				initTurboObserver($turboBody)
			if (!$turboBody && Observers.turbo.running) disposeAll()
			if ($wkofDialogBg && (!Observers.wkofDiaglogBg.running || $lastWkofDialogBg !== $wkofDialogBg))
				initWkofDialogObserver($wkofDialogBg)
			if (!$wkofDialogBg && Observers.wkofDiaglogBg.running) Observers.wkofDiaglogBg.dispose()
			$lastTurboBody = $turboBody
			$lastWkofDialogBg = $wkofDialogBg
		}
	})()
	const updateRunner = () => {
		const $turboBody = document.getElementById('turbo-body')
		const $wkofDialogBg = document.getElementById('wkofs_bkgd')
		wkFocusRunner($turboBody, $wkofDialogBg)
	}
	setInterval(updateRunner, 200)
	updateRunner()
})()