Lichess - Interactive Studies

Enhanced interactive studies in Lichess.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Lichess - Interactive Studies
// @namespace    lichess
// @version      0.2
// @description  Enhanced interactive studies in Lichess.
// @author       flusflas
// @match        https://lichess.org/study/*
// @icon         data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @license      MIT
// @grant        GM.getValue
// @grant        GM.setValue
// @require      https://code.jquery.com/jquery-3.7.0.min.js
// ==/UserScript==

/* globals $ */

let studyDropdownCss = [
    '#custom-app {' +
    '    overflow: hidden;' +
    '    width: 20rem;' +
    '    max-width: 100vw;' +
    '    right: 0;' +
    '    border-radius: 3px 0 0 3px;' +
    '}',
    '.custom-dropdown-item-label {' +
    '    padding: 2rem;' +
    '}',
    '.custom-label-span {' +
    '    padding: 1rem .5rem;' +
    '}'
];

const studyDropdownHtml = '' +
      '<button id="custom-toggle" class="toggle link">' +
      '    <span title="Notifications: 0" aria-label="Notifications: 0"' +
      '          class="data-count" data-count="0" data-icon="" "=""></span>' +
      '</button>' +
      '<div id="custom-app" class="dropdown">' +
      '    <div class="notifications">' +
      '        <label class="site_notification" for="cb-force-preview">' +
      '            <input type="checkbox" id="cb-force-preview" name="cb-force-preview" value="Force Preview"">' +
      '            <span class="custom-label-span">Force Preview mode</span>' +
      '        </label>' +
      '        <label class="site_notification" for="cb-random-chapters">' +
      '            <input type="checkbox" id="cb-random-chapters" name="cb-random-chapters" value="Preview Random">' +
      '            <span class="custom-label-span">Show chapters in random order</span>' +
      '        </label>' +
      '    </div>' +
      '</div>';


const forcePreviewValueKey = "lichess_study_force_preview";
const randomChaptersValueKey = "lichess_study_random_chapters";

var forcePreview = GM.getValue(forcePreviewValueKey, false);
var randomChapters = GM.getValue(randomChaptersValueKey, false);


let customMenuController = null;
let previewModeController = null;
let randomChaptersController = null;


/**
 * Creates and handles the custom dropdown menu added to the top bar.
 */
class CustomMenuController {
    constructor() {
        let self = this;

        const observer = new MutationObserver((mutationsList, observer) => {
            if (document.querySelector('.site-buttons') !== null) {
                observer.disconnect();
                customMenuController.#loadMenu();
            }
        });

        observer.observe(document.body, {childList: true, subtree: true});
    }

    #loadMenu() {
        // Loads the custom styles
        var styleSheet = document.createElement("style");
        styleSheet.innerText = studyDropdownCss.join(' ');
        document.head.appendChild(styleSheet);
        this.#ensureMenuStyles();

        // Creates and insert the custom menu
        const studyDropdown = document.createElement('div');
        studyDropdown.innerHTML = studyDropdownHtml;

        const siteButtons = document.querySelector('.site-buttons');
        document.querySelector('.site-buttons').insertBefore(studyDropdown, siteButtons.firstChild);

        $('#cb-force-preview').prop('checked', forcePreview);
        $('#cb-force-preview').change(function() {
            forcePreview = this.checked;
            GM.setValue(forcePreviewValueKey, this.checked);
            randomChaptersController.resetChapterList();
            if (this.checked) previewModeController.enterPreviewMode();
        });

        $('#cb-random-chapters').prop('checked', randomChapters);
        $('#cb-random-chapters').change(function() {
            randomChapters = this.checked;
            GM.setValue(randomChaptersValueKey, this.checked);
            if (this.checked) randomChaptersController.randomizeChapers(true);
        });
    }

    /**
     * Hacky workaround to ensure the styles of the custom menu are applied
     * by just sending mouseover events to the notify toggle button (the custom
     * menu uses some notification classes for styling, and for some reason
     * an interaction with the notify button is needed).
     */
    #ensureMenuStyles() {
        var refreshIntervalId = setInterval(fname, 100);
        function fname() {
            const notifyButton = document.querySelector('#notify-toggle');
            notifyButton.dispatchEvent(new MouseEvent('mouseover'));
        }

        setTimeout(() => clearInterval(refreshIntervalId), 3000);
    }
}


/**
 * Handles the behavior of the "Force Preview mode" option, entering
 * automatically in Preview mode every time a chapter is selected if
 * the option is enabled.
 */
class PreviewModeController {
    constructor() {
        let self = this;

        $(document).ready(function() {
            const analysisPanel = document.querySelector(".analyse__underboard");

            const observer = new MutationObserver((mutationsList, observer) => {
                for (const mutation of mutationsList) {
                    if (mutation.type === 'childList') {
                        for (const addedNode of mutation.addedNodes) {
                            if (addedNode.classList.contains('gamebook-buttons') || addedNode.classList.contains('study__buttons')) {
                                const previewButton = analysisPanel.querySelector(".preview");
                                self.#onPreviewModeChange(previewButton.classList.contains("active"));
                            }
                        }
                    }
                }
            });

            observer.observe(analysisPanel, {childList: true, subtree: true});

            if (forcePreview) {
                previewModeController.enterPreviewMode();
            }
        });
    }

    #onPreviewModeChange(previewMode) {
        const previewButton = document.querySelector(".analyse__underboard .preview");

        if (previewButton.classList.contains('active')) {
            previewButton.addEventListener('click', function() {
                forcePreview = false;
                GM_setValue(forcePreviewValueKey, false);
                $('#cb-force-preview').prop('checked', false);
            });
        } else {
            if (forcePreview) {
                this.enterPreviewMode();
            }
        }
    }

    isInPreviewMode() {
        return document.querySelector(".analyse__underboard .preview").classList.contains("active");
    }

    enterPreviewMode() {
        var previewButton = document.querySelector(".preview:not(.active)");
        if (randomChapters) {
            if (randomChaptersController.nextChapterList.length === 0) {
                randomChaptersController.randomizeChapers(true);
            }
        }
        if (previewButton !== null) previewButton.click();
    }

    exitPreviewMode() {
        var previewButton = document.querySelector(".preview.active");
        if (previewButton !== null) previewButton.click();
    }
}


/**
 * Handles the behavior of the "Show chapters in random order" option.
 * If it is enabled, chapters are loaded in random order (without repetition)
 * when the user finishes an interactive chapter and clicks the "Next chapter"
 * button.
 */
class RandomChaptersController {
    nextChapterList = [];
    #index = -1;

    constructor() {
        const self = this;

        $(document).ready(function() {
            // Creates an observer to know when the chapter is complete (the retry button appears)
            const observer = new MutationObserver((mutationsList, observer) => {
                for (const mutation of mutationsList) {
                    if (mutation.type === 'childList') {
                        for (const addedNode of mutation.addedNodes) {
                            if (addedNode.nodeType === Node.ELEMENT_NODE && addedNode.classList.contains('feedback')) {
                                const retryButton = document.querySelector(".gamebook .floor .retry");
                                if (retryButton !== null) {
                                    self.#onChapterCompleted();
                                }
                            }
                        }
                    }
                }
            });

            observer.observe(document, {childList: true, subtree: true});

            if (randomChapters) self.randomizeChapers(true);
        });
    }

    #onChapterCompleted() {
        if (!randomChapters) return;

        let self = this;

        let nextButtonHtml = '<button id="next-button" class="next text" data-icon="" type="button">Next chapter</button>';
        let nextShuffleHtml = '<button id="next-button" class="next text" data-icon="" type="button">Repeat</button>';

        let feedbackPanel = document.querySelector(".feedback.end");
        let nextButton = document.querySelector(".feedback.end .next");
        if (!this.hasNext()) {
            if (nextButton !== null) nextButton.style.display = "none";
            feedbackPanel.insertAdjacentHTML("afterbegin", nextShuffleHtml);
            $("#next-button").click(function() {
                self.randomizeChapers(false);
                self.next();
            });
        } else {
            if (nextButton !== null) nextButton.style.display = "none";
            feedbackPanel.insertAdjacentHTML("afterbegin", nextButtonHtml);
            $("#next-button").click(function() {
                self.next();
            });
        }
    }

    resetChapterList() {
        this.nextChapterList = [];
        this.#index = -1;
    }

    randomizeChapers(excludeActive) {
        if (excludeActive) {
            this.nextChapterList = Array.from(document.querySelectorAll(".study__chapters .draggable:not(.active)"));
        } else {
            this.nextChapterList = Array.from(document.querySelectorAll(".study__chapters .draggable"));
        }
        this.shuffleArray(this.nextChapterList);
        this.#index = -1
    }

    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]];
        }
    }

    hasNext() {
        return this.#index < this.nextChapterList.length - 1;
    }

    next() {
        if (this.nextChapterList.length === 0) {
            this.randomizeChapers();
        }

        if (this.nextChapterList.length === 0 || !this.hasNext()) {
            return null
        }

        this.#index++;

        if (this.nextChapterList[this.#index].classList.contains("active")) {
            // Retry the active chapter
            document.querySelector(".gamebook .floor .retry").click();
        } else {
            this.nextChapterList[this.#index].click();
        }
    }

    handleFeedback() {
        if (!randomChapters) return;

        let retryButton = document.querySelector(".feedback.end .retry");
        let nextButton = document.querySelector(".feedback.end .next");

        if (this.hasNext() && nextButton === null) {
            // Add next button
        }
    }
}


(function() {
    'use strict';

    customMenuController = new CustomMenuController();
    previewModeController = new PreviewModeController();
    randomChaptersController = new RandomChaptersController();
})();