Enhanced interactive studies in Lichess.
// ==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();
})();