Greasy Fork

Greasy Fork is available in English.

Enhanced Ankiweb

Userscript that enhances the study page of Ankiweb with features and stuff

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name        Enhanced Ankiweb
// @namespace   Violentmonkey Scripts
// @match       https://ankiuser.net/study/
// @version     1.0
// @author      ankiwanker
// @grant       GM_setValue
// @grant       GM_getValue
// @grant       GM_addStyle
// @license     MIT
// @description Userscript that enhances the study page of Ankiweb with features and stuff
// ==/UserScript==

(() => {
    let zenMode = false

    class PsuedoAudioPlayer {
        constructor() {
            this.playing = false
            this.index = 0
            this.audioElements = []
            this.currentAudioElement = null
        }
        reset() {
            this.index = 0
            this.audioElements = []
            this.currentAudioElement = null
        }
        play() {
            this.playing = true
            this.setupListener()
            this.currentAudioElement.play()
        }
        playNext() {
            if (this.audioElements.length > this.index) {
                this.currentAudioElement = this.audioElements[this.index]
                this.index++
                this.play()
            }
        }
        setupListener() {
            this.currentAudioElement.addEventListener("ended", () => {
                this.playing = false
                this.playNext()
            }, { once: true })
        }
    }
    function log(...args) {
        console.log("[Enhanced Ankiweb]", ...args)
    }

    function showSettingsDialog() {
        // Create the dialog container
        const dialogContainer = document.createElement("div");
        dialogContainer.style.position = "fixed";
        dialogContainer.style.top = "50%";
        dialogContainer.style.left = "50%";
        dialogContainer.style.transform = "translate(-50%, -50%)";
        dialogContainer.style.backgroundColor = "white";
        dialogContainer.style.padding = "20px";
        dialogContainer.style.border = "1px solid black";
        dialogContainer.style.zIndex = "9999";

        // Create the "Show only 'Again' and 'Good' buttons" option
        const showButtonsOption = document.createElement("div");
        const showButtonsCheckbox = document.createElement("input");
        showButtonsCheckbox.type = "checkbox";
        showButtonsCheckbox.id = "showButtonsCheckbox";
        showButtonsCheckbox.style = "margin-right: 5px;"
        showButtonsCheckbox.checked = GM_getValue("onlyAgainGoodButtons", true)
        const showButtonsLabel = document.createElement("label");
        showButtonsLabel.innerText = "Show only 'Again' and 'Good' buttons ('Good' will be shown as 'Pass')";
        showButtonsLabel.htmlFor = "showButtonsCheckbox";
        showButtonsOption.appendChild(showButtonsCheckbox);
        showButtonsOption.appendChild(showButtonsLabel);

        // Create the "Bind keyboard keys to answer buttons" option
        const bindKeysOption = document.createElement("div");
        const bindKeysCheckbox = document.createElement("input");
        bindKeysCheckbox.type = "checkbox";
        bindKeysCheckbox.id = "bindKeysCheckbox";
        bindKeysCheckbox.style = "margin-right: 5px;"
        bindKeysCheckbox.checked = GM_getValue("bindKeyboardKeys", true)
        const bindKeysLabel = document.createElement("label");
        bindKeysLabel.innerText = "Bind keyboard keys to answer buttons";
        bindKeysLabel.htmlFor = "bindKeysCheckbox";
        bindKeysOption.appendChild(bindKeysCheckbox);
        bindKeysOption.appendChild(bindKeysLabel);

        // Create the "Make <audio> elements better" option
        const audioOption = document.createElement("div");
        const audioCheckbox = document.createElement("input");
        audioCheckbox.type = "checkbox";
        audioCheckbox.id = "audioCheckbox";
        audioCheckbox.style = "margin-right: 5px;";
        audioCheckbox.checked = GM_getValue("betterAudioElements", true);
        const audioLabel = document.createElement("label");
        audioLabel.innerText = "Make <audio> elements better";
        audioLabel.htmlFor = "audioCheckbox";
        audioOption.appendChild(audioCheckbox);
        audioOption.appendChild(audioLabel);

        // Create the save button
        const saveButton = document.createElement("button");
        saveButton.innerText = "Save";
        saveButton.style = "margin-right: 5px;"
        saveButton.addEventListener("click", () => {
            const showButtons = showButtonsCheckbox.checked;
            const bindKeys = bindKeysCheckbox.checked;
            const makeBetterAudio = audioCheckbox.checked;

            // Save the settings or perform any necessary actions based on the selected options
            GM_setValue("onlyAgainGoodButtons", showButtons)
            GM_setValue("bindKeyboardKeys", bindKeys)
            GM_setValue("betterAudioElements", makeBetterAudio);

            // Close the settings dialog
            document.body.removeChild(dialogContainer);
        });

        // Create the close button
        const closeButton = document.createElement("button");
        closeButton.innerText = "Close";
        closeButton.addEventListener("click", () => {
            document.body.removeChild(dialogContainer);
        });

        // Append elements to the dialog container
        //dialogContainer.appendChild(dialogTitle);
        dialogContainer.appendChild(showButtonsOption);
        dialogContainer.appendChild(bindKeysOption);
        dialogContainer.appendChild(audioOption)
        dialogContainer.appendChild(saveButton);
        dialogContainer.appendChild(closeButton);

        // Append the dialog container to the document body
        document.body.appendChild(dialogContainer);
    }

    function injectMinimalStyle() {
        GM_addStyle(`
        #logo {
            display: none;
        }

        #rightStudyMenu {
            display: none;
        }

        body > nav {
            height: 30px;
            background-color: #b5b5b5 !important;
        }

        .nav-link {
            color: whitesmoke !important;
        }

        .enhanced-settings-nav-item {
            color: #1b00ff !important;
            cursor: pointer;
        }

        .align-middle {
            vertical-align: unset !important;
        }

        /* Make the "AnkiWeb" span the same size as the other nav items */
        body > nav > div > a > span {
            /* anki's blue */
            line-height: 2;
            color: #007bff !important;
            font-size: 16px;
        }

        .btn {
            padding: 10px !important;
            line-height: 1 !important;
        }
        `)
    }

    function getStudyStatsHtml() {
        if (ankiStudy.currentCard) {
            const countIndex = ankiStudy.currentCard.countIndex
            const stats1 = ankiStudy.stats[1]
            const stats2 = ankiStudy.stats[2]
            const stats3 = ankiStudy.stats[0]
            return `
            <div style="text-align: center;">
                <div>
                    ${countIndex === 0 ? `<u><font color=#0000ff>${stats3}</font></u>` : `<font color=#0000ff>${stats3}</font>`}
                     + 
                    ${countIndex === 1 ? `<u><font color=#990000>${stats1}</font></u>` : `<font color=#990000>${stats1}</font>`}
                     + 
                    ${countIndex > 1 || countIndex < 0 ? `<u><font color=#009900>${stats2}</font></u>` : `<font color=#009900>${stats2}</font>`}
                </div>
            </div>
            `
        }
        return ""
    }

    function injectStatsAboveButtons() {
        GM_addStyle(`
        .pt-3 {
            padding-top: 5px !important;
        }
        `)
        const ansArea = document.getElementById("ansarea")
        const statusDiv = document.createElement("div")
        statusDiv.id = "statsDiv"
        statusDiv.innerHTML = getStudyStatsHtml()
        ansArea.insertBefore(statusDiv, ansArea.firstChild)
    }

    async function toggleZenMode() {
        // basically injects css to hide everything except the quiz container
        // this should be toggable, so if the user presses X again, it should go back to normal
        const zenStyle = document.getElementById("zenStyle")
        if (zenStyle) {
            zenMode = false
            document.head.removeChild(zenStyle)
        } else {
            const zenStyle = document.createElement("style")
            zenStyle.id = "zenStyle"
            zenStyle.innerText = `
            body > nav {
                display: none !important;
            }

            #leftStudyMenu {
                display: none !important;
            }
            `
            document.head.appendChild(zenStyle)
            zenMode = true
        }
    }

    // if the user exits fullscreen, remove the zen style
    document.addEventListener("fullscreenchange", () => {
        log("fullscreenchange event fired")
        if (!document.fullscreenElement) {
            const zenStyle = document.getElementById("zenStyle")
            if (zenStyle) document.head.removeChild(zenStyle)
        } else {
            // if the user enters fullscreen, add the zen style
            toggleZenMode()
        }
    })

    const ankiStudy = study
    if (ankiStudy) {
        log(`"study" object exists.`)
        document.body.style = "overflow: hidden;"
        const containerElement = document.querySelector("body > main")
        const qaElement = document.getElementById("qa")
        const qaBoxElement = document.getElementById("qa_box")
        const quizElement = document.getElementById("quiz")
        qaBoxElement.style = "overflow: hidden;"
        qaBoxElement.style.userSelect = "none"
        // the quiz element should of the same height as the parent element
        // replace the "_resizeFonts", because the way they're doing it is laughable
        ankiStudy.__proto__._resizeFonts = function () {
            qaElement.style = `transform-origin: center top; transform: scale(${this.zoom});`
            adjustQuizHeight()
        }
        function adjustQuizHeight() {
            // get the height from the container
            const containerHeight = containerElement.getBoundingClientRect().height
            quizElement.style.height = `${containerHeight}px`
            qaBoxElement.style.height = `${containerHeight}px`
            qaElement.style.height = `${containerHeight}px`
        }
        window.addEventListener("resize", () => {
            adjustQuizHeight()
        })
        qaElement.removeAttribute("style")
        injectMinimalStyle()
        injectStatsAboveButtons()

        // hide mouse cursor after 3 seconds
        let mouseTimer = null
        document.addEventListener("mousemove", () => {
            if (mouseTimer) {
                clearTimeout(mouseTimer)
                mouseTimer = null
            }
            document.body.style.cursor = "default"
            mouseTimer = setTimeout(() => {
                document.body.style.cursor = "none"
            }, 3000)
        })

        // recover the zoom level
        const zoomLevel = GM_getValue("zoomLevel", 1)
        ankiStudy.zoom = zoomLevel
        ankiStudy._resizeFonts()

        const psuedoAudioPlayer = new PsuedoAudioPlayer()

        // Create a new MutationObserver
        const audioMutationObserver = new MutationObserver((mutationsList, observer) => {
            // Iterate through each mutation
            for (const mutation of mutationsList) {
                // Check if nodes were added
                if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
                    // Iterate through the added nodes
                    for (const node of mutation.addedNodes) {
                        // Check if the node is an <audio> element
                        if (node.nodeName === "AUDIO") {
                            // Do something with the <audio> element
                            log("New <audio> element added:", node);
                            // Add your custom logic here for handling the <audio> elements within #quiz
                            if (GM_getValue("betterAudioElements", true)) {
                                // make the audio element nicer
                                node.style = "width: 150px; height: 30px;"
                                psuedoAudioPlayer.audioElements.push(node)
                                if (!psuedoAudioPlayer.playing) psuedoAudioPlayer.playNext()
                            }
                        }
                    }
                }
            }
        });

        // Observe changes in the #quiz element
        audioMutationObserver.observe(quizElement, {
            childList: true,
            subtree: true,
        });


        // append our fancy settings navitem
        const leftSideNav = document.querySelector("#navbarSupportedContent > ul.navbar-nav.mr-auto")
        const liItem = document.createElement("li")
        const anchorItem = document.createElement("a")
        anchorItem.innerText = "Settings"
        anchorItem.classList.add("enhanced-settings-nav-item")
        anchorItem.classList.add("nav-link")

        anchorItem.addEventListener("click", showSettingsDialog)

        liItem.classList.add("nav-item")
        liItem.appendChild(anchorItem)
        leftSideNav.appendChild(liItem)

        // replace the _getButtons method with our method
        const getButtonsOrig = ankiStudy.__proto__._getButtons
        ankiStudy.__proto__._getButtons = function () {
            if (GM_getValue("onlyAgainGoodButtons", true)) {
                const labels = ankiStudy.currentCard.buttonLabels
                const goodLabel = labels.length === 4 ? 2 : 1
                const goodNum = labels.length === 4 ? 3 : 2

                return [
                    [1, "Again", labels[0]],
                    [goodNum, "Pass", labels[goodLabel]]
                ]
            } else {
                // original function
                return getButtonsOrig.call(this, arguments)
            }
        }

        const checkNextCardOrig = ankiStudy.__proto__._checkNextCard
        ankiStudy.__proto__._checkNextCard = function () {
            psuedoAudioPlayer.reset()
            return checkNextCardOrig.call(this, arguments)
        }



        // hook on "bigger" and "smaller" methods
        const biggerOrig = ankiStudy.__proto__.bigger
        ankiStudy.__proto__.bigger = function () {
            // save the zoom level
            biggerOrig.call(this, arguments)
            GM_setValue("zoomLevel", ankiStudy.zoom)
        }

        const smallerOrig = ankiStudy.__proto__.smaller
        ankiStudy.__proto__.smaller = function () {
            smallerOrig.call(this, arguments)
            GM_setValue("zoomLevel", ankiStudy.zoom)
        }

        // hook on "updateStatus" method
        const updateStatusOrig = ankiStudy.__proto__.updateStatus
        ankiStudy.__proto__.updateStatus = function () {
            updateStatusOrig.call(this, arguments)
            const statsDiv = document.getElementById("statsDiv")
            if (statsDiv) {
                statsDiv.innerHTML = getStudyStatsHtml()
            }
        }

        // this hacky af
        HTMLElement.prototype.focus = function () { }
        HTMLElement.prototype.scrollIntoView = function () { }

        window.addEventListener("keyup", async (event) => {
            event.stopPropagation()
            event.preventDefault()

            // Zen-mode
            if (event.code === "KeyX") {
                log(`toggling zen mode`)
                await toggleZenMode()
                return
            }

            // manage zoom with + and -
            if (event.key === "+") {
                log(`zooming in`)
                ankiStudy.bigger()
                return
            }
            if (event.key === "-") {
                log(`zooming out`)
                ankiStudy.smaller()
                return
            }


            if (!GM_getValue("bindKeyboardKeys", true)) return

            if (ankiStudy.state === "questionShown") {
                log(`current state is questionShown, drawing answer`)
                if (event.key === "Enter" || event.code === "Space") {
                    ankiStudy.drawAnswer()
                    return
                }
            }
            if (ankiStudy.state === "answerShown") {
                log(`current state is answerShown, answering card`)
                const buttonLabels = ankiStudy.currentCard.buttonLabels
                const goodNum = buttonLabels.length === 4 ? 3 : 2
                switch (event.key) {
                    case "1": // Again
                        ankiStudy.answerCard(1)
                        break
                    case "2": // Hard
                        if (!GM_getValue("onlyAgainGoodButtons", true)) {
                            ankiStudy.answerCard(2)
                        }
                        break
                    case "3": // Good
                        if (!GM_getValue("onlyAgainGoodButtons", true)) {
                            ankiStudy.answerCard(3)
                        }
                        break
                    case "4": // Easy
                        if (!GM_getValue("onlyAgainGoodButtons", true)) {
                            ankiStudy.answerCard(4)
                        }
                        break
                    case "Enter": // Recommended (Good)
                        ankiStudy.answerCard(goodNum)
                        break
                }
            }
        })
    }
})()