您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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() })()
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址