AO3: Glossary Definition Previews

click a glossary term in the AO3 FAQ to get its definition in a dialog without leaving the FAQ page

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         AO3: Glossary Definition Previews
// @description  click a glossary term in the AO3 FAQ to get its definition in a dialog without leaving the FAQ page
// @version      2.0
// @author       escctrl
// @namespace    https://greasyfork.org/en/users/906106-escctrl
// @match        https://archiveofourown.org/faq/*
// @require      https://ajax.googleapis.com/ajax/libs/jquery/3.7.0/jquery.min.js
// @require      https://ajax.googleapis.com/ajax/libs/jqueryui/1.13.2/jquery-ui.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/jqueryui-touch-punch/0.2.3/jquery.ui.touch-punch.min.js
// @grant        none
// @license      MIT
// ==/UserScript==

(function($) {
    'use strict';

    // localstorage glossary DB Map as global variable:
    // refreshed -> last refreshed date, lang -> glossary language stored, #anchor -> content
    // load the glossary text from localstorage
    var gDB = new Map(JSON.parse(localStorage.getItem('glossary')));

    // which language are we viewing? that's the glossary we want to load
    var lang = new URLSearchParams(location.search);
    lang = lang.get('language_id');

    loadGlossary();

    function loadGlossary() {
        // reload the glossary text into localstorage...
        if (gDB.size == 0 || // if nonexistant
            new Date(gDB.get('refreshed')) < createDate(0, -6, 0) || // if more than 6 months old (FAQs don't get updated very often)
            gDB.get('lang') !== lang) // if wrong language
        {
            gDB = new Map([["refreshed", new Date().toString()], ["lang", lang]]); // reset any old loaded data

            // read the matching language glossary page and load the descriptions
            $.get('https://archiveofourown.org/faq/glossary?language_id='+lang, function(response) {
                // nothing to do here, all interactions are in done() and failed()
            }).done(function(response) {
                // not all languages have a translated Glossary, yet the FAQs still link to it. automatically switch back to English in that case
                if ($(response).find('div#faq h3').length === 0) {
                    lang = "en";
                    loadGlossary();
                    return;
                }

                // pull all the headings and their anchors into gDB
                $(response).find('div#faq h3').each(function() {
                    // anchor is the id on the <h3>
                    let anchor = $(this).attr('id');
                    // content is the following text until the next <h3>
                    let content = "";
                    $(this).nextUntil('h3').each(function() {
                        // fixing the fact that crosslinks inside the Glossary need a full relative URL to work within the dialog
                        $(this).find('a[href^="#"]').each(function(i, elem) {
                            let href = $(elem).attr('href');
                            $(elem).attr('href', '/faq/glossary'+href);
                        });
                        content += this.outerHTML;
                    });
                    // updating that anchor -> content in the Map
                    gDB.set(anchor, content);
                });
                // gDB is updated in memory, now saving to localstorage
                localStorage.setItem('glossary', JSON.stringify(Array.from(gDB.entries())));

                // display called after async storage refresh
                displayGlossary();

            // thanks to Przemysław Sienkiewicz on Stackoverflow for the code to catch error responses https://stackoverflow.com/a/40256829
            }).fail(function(data, textStatus, xhr) {
                //This shows status code eg. 429
                console.log("Loading Glossary in background failed. Error:", data.status);
            });
        }
        // display called immediately if storage is still up-to-date
        else displayGlossary();
    }

    function displayGlossary() {
        // if the background is dark, use the dark UI theme to match
        let dialogtheme = lightOrDark($('body').css('background-color')) == "dark" ? "vader" /*"ui-darkness"*/ : "base";
        let highlight = dialogtheme == "vader" ? "#970000" : "gold";

        var dlg = "#glossary-entry";

        $("head").append(`<link rel="stylesheet" href="https://code.jquery.com/ui/1.13.2/themes/${dialogtheme}/jquery-ui.css">`)
        .append(`<style tyle="text/css">${dlg}, .ui-dialog {font-size: revert; line-height: 1.286; clear: both;} /* needs clear to avoid header getting huge on Ctrl+F5 */
        ${dlg} a {cursor:pointer;} .glossary-highlight { background-color: ${highlight}; }
        .ui-dialog-titlebar button {background: revert; box-shadow: revert;} ${dlg} { font-size: inherit; }</style>`);

        // optimizing the size of the GUI in case it's a mobile device
        let dialogwidth = parseInt($("body").css("width")); // parseInt ignores letters (px)
        dialogwidth = dialogwidth > 700 ? 700 : dialogwidth * 0.9;

        // we gotta work with dialogs if we want to allow users to click a link within the glossary text.
        $("body").append(`<div id="glossary-entry"></div>`);
        $(dlg).dialog({
            modal: false,
            autoOpen: false,
            resizable: false,
            width: dialogwidth,
            title: "Glossary Definition",
            // remove highlight of clicked glossary term when the dialog closes
            close: function( event, ui ) { $('a.glossary-highlight').removeClass('glossary-highlight'); }
        });

        // the html collection of all glossary links is different if we're already viewing the glossary
        let gLinks = (location.pathname === "/faq/glossary") ? 'a[href^="#"]' : 'a[href*="/faq/glossary#"]';

        // on links leading to the glossary
        $(gLinks)
            .after(`<sup>&#x1f6c8;</sup>`) // add a special icon 🛈
            .prop("title", "click to view glossary definition")
            .on("click", function(e) { // add an event handler for managing the clicks
                e.preventDefault(); // stop it from going to the glossary page
                e.cancelBubble = true;

                // grab the anchor
                let anchor = new URL($(this).prop("href")); // prop will always contain the full URL
                anchor = anchor.hash.slice(1); // we're only interested in the anchor, minus the hash sign
                let content = gDB.get(anchor); // grab the glossary text for that anchor as the dialog content

                // prepare the (still hidden) dialog:
                $(dlg)
                    .html(content) // add the dynamic content
                    .dialog("option", "position", { my: "left top", at: "left bottom", of: e.target } ) // position the dialog at the clicked term
                    .dialog('open'); // finally, open the dialog

                // remove previous highlighting, in case user clicked from one glossary term directly to the next without closing the dialog
                $('a.glossary-highlight').removeClass('glossary-highlight');
                // highlight the term which the user clicked on, for visual reference
                $(e.target).addClass("glossary-highlight");
            });
    }


})(jQuery);

// convenience function to be able to pass minus values into a Date, so JS will automatically shift correctly over month/year boundaries
// thanks to Phil on Stackoverflow for the code snippet https://stackoverflow.com/a/37003268
function createDate(days, months, years) {
    var date = new Date();
    date.setFullYear(date.getFullYear() + years);
    date.setMonth(date.getMonth() + months);
    date.setDate(date.getDate() + days);
    return date;
}
// helper function to determine whether a color (the background in use) is light or dark
// https://awik.io/determine-color-bright-dark-using-javascript/
function lightOrDark(color) {
    var r, g, b, hsp;
    if (color.match(/^rgb/)) { color = color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/);
        r = color[1]; g = color[2]; b = color[3]; }
    else { color = +("0x" + color.slice(1).replace(color.length < 5 && /./g, '$&$&'));
        r = color >> 16; g = color >> 8 & 255; b = color & 255; }
    hsp = Math.sqrt( 0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b) );
    if (hsp>127.5) { return 'light'; } else { return 'dark'; }
}