AO3: Sticky Comment Box

gives you a comment box that stays in view as you scroll and read the story

Від 15.03.2024. Дивіться остання версія.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         AO3: Sticky Comment Box
// @namespace    https://greatest.deepsurf.us/en/users/906106-escctrl
// @version      2.0
// @description  gives you a comment box that stays in view as you scroll and read the story
// @author       escctrl
// @license      MIT
// @match        *://archiveofourown.org/works/*
// @match        *://archiveofourown.org/collections/*/works/*
// @exclude      *://archiveofourown.org/works/*/new
// @exclude      *://archiveofourown.org/works/*/edit*
// @exclude      *://archiveofourown.org/works/new*
// @exclude      *://archiveofourown.org/works/search*
// @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
// ==/UserScript==

(function($) {
    'use strict';

    // despite the @excludes, there are always ways that editing a work ends up with AO3's URL being just /works/xxxxx >:(
    // so we can't rely on URLs, we gotta check for ourselves and stop if there's no fic to display
    if ($('#main #chapters').length == 0) return;

    // select the work ID from the URL - we save cache with this, so it won't matter what the rest of the URL is (collections, chapters)
    const workID = new URL(window.location.href).pathname.match(/\/works\/(\d+)/i)[1];

    // let's figure out if there are multiple chapters that could be commented on
    const chapterIDs = $('#main ul.work.navigation ul#chapter_index').length > 0 ? $('#main ul.work.navigation ul#chapter_index select#selected_id option').toArray() // when in chapter-by-chapter view, there's a Chapter Index button
                   : $('#main ul.work.navigation li.chapter.bychapter').length > 0 ? $('.chapter.preface h3.title a').toArray() // when in entire-work view, there's a Chapter By Chapter button
                   : []; // and if neither exists, it's a work without chapters

    // if we're in entire-work view, we wanna give a hint to the user which chapter they're currently seeing
    if ($('#main ul.work.navigation li.chapter.bychapter').length > 0) {
        $(document).on('scrollend', () => { whatsInView(); }); // listen to scrolling for updates
    }

    // gets called by scrolling events, and when dialog is first created
    function whatsInView() {
        // here we want to figure out which chapter is currently in view
        $(chapterIDs).each((i) => {
            let rect = $('#chapter-'+(i+1)).get(0).getBoundingClientRect();
            if ((rect.top >= 0 && rect.top <= (window.innerHeight || document.documentElement.clientHeight)) || // top edge is visible
                (rect.bottom >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight)) || // bottom edge is visible
                (rect.top < 0 && rect.bottom > (window.innerHeight || document.documentElement.clientHeight))) { // top is above and bottom is below viewport (we're seeing the middle of it)

                // based on what's in view, we can update the selection
                $('#float_cmt_chap select option').eq(i+1).text(`Chapter ${(i+1)} (viewing)`);
            }
            // the others get reset
            else $('#float_cmt_chap select option').eq(i+1).text(`Chapter ${(i+1)}`);
        });
    }

    // sticky button to open the comment box
    let cmtButton = `<div id="float_cmt_toggle"><button>Comment Box</button></div>`;
    $('body').append(cmtButton);

    // listening to button click: open or close the dialog
    $('#float_cmt_toggle').on('click', (e) => {
        toggleCommentBox();
    });

    // this is called by the button and also the keyboard shortcut
    function toggleCommentBox() {
        if ($(dlg+":hidden").length > 0) openCommentBox();
        else if ($(dlg+":visible").length > 0) closeCommentBox();
    }

    var dlg = "#float_cmt_dlg";

    let dialogtheme = lightOrDark($('body').css('background-color')) == "dark" ? "ui-darkness" : "base"; // if the background is dark, use the dark UI theme to match
    let fontsize = $("#main #chapters .userstuff").css('font-size'); // enforce the reading font size for the dialog
    $("head").append(`<link rel="stylesheet" href="https://code.jquery.com/ui/1.13.2/themes/${dialogtheme}/jquery-ui.css">`)
    .append(`<style tyle="text/css">.ui-dialog ${dlg}, .ui-dialog .ui-dialog-titlebar, .ui-dialog .ui-dialog-buttonpane button { font-size: ${fontsize}; }
    .ui-dialog .ui-dialog-buttonpane button { min-width: 2em; min-height: 2em; padding: 0 0.5em; }
    .ui-dialog .ui-dialog-buttonpane { padding: 0; margin: 0; }
    ${dlg} select { width: unset; min-width: unset; position: relative; bottom: 0.2em; }
    ${dlg} input { width: 10em; min-width: unset; }
    #float_cmt_counter, #float_cmt_settings_hint{ font-size: 80%; padding: 0.2em; margin: 0.2em 0; }
    #float_cmt_toggle { position: fixed; bottom: 0.5em; right: 0.5em; z-index: 3; }
    #float_cmt_toggle button { height: unset; font-size: ${fontsize}; }</style>`);

    // prepping the dialog (without opening it)
    createCommentBox();
    var scrollPOS;

    // prepares the dialog and loads the cache into it
    function createCommentBox() {
        // designing the floating box
        $("body").append(`<div id="float_cmt_dlg"></div>`);

        // optimizing the GUI in case it's a mobile device
        let screen = parseInt($("body").css("width")); // parseInt ignores letters (px)
        let buttonText = screen <= 500 ? false : true;
        let dialogwidth = screen <= 500 ? screen * 0.9 : 500;
        let resize = screen <= 500 ? false : true;

        $(dlg).dialog({
            modal: false,
            autoOpen: false,
            resizable: resize,
            draggable: true,
            width: dialogwidth,
            position: { my: "right bottom", at: "right bottom", of: "window" },
            title: "Comment",
            buttons: [
                { text: "Settings", icon: "ui-icon-gear", showLabel: buttonText, click: () => { toggleSettings(); } },
                { text: "Quote", icon: "ui-icon-caret-2-e-w", showLabel: buttonText, click: () => { grabHighlight(); } },
                { text: "Discard", icon: "ui-icon-trash", showLabel: buttonText, click: () => { discardComment(); } },
                { text: "Post", icon: "ui-icon-comment", showLabel: buttonText, click: () => { submitComment(); } },
                { text: "Close", icon: "ui-icon-close", showLabel: buttonText, click: () => { closeCommentBox(); } },
            ],
            // positioning stuff below is so that it SCROLLS WITH THE PAGE JFC https://stackoverflow.com/a/9242751/22187458
            create: function(event, ui) {
                $(event.target).parent().css('position', 'fixed');
                // and also to put the dialog where it was last left across pageloads
                let cachemap = new Map(JSON.parse(localStorage.getItem('floatcmt')));
                if (cachemap.get('pos')) {
                    let pos = JSON.parse(cachemap.get('pos'));
                    pos.of = $(window);
                    $(dlg).dialog('option','position', pos);
                }
                // issue: if you drag it around so far that the screen begins to scroll, the dialog disappears. need to refresh the page to get it back
                // workaround: force the dialog to stay within the visible screen - no dragging outside of viewport means it can't disappear
                $(dlg).dialog("widget").draggable("option","containment","window");
                // issue: to fix the return-to-top scrolling, the standard close button would need hookins to the beforeClose and close events
                // workaround: simply not display that x in the title, there's anyways the Close button at the bottom
                //$(dlg).parent().find(".ui-dialog-titlebar-close").hide();
            },
            resizeStop: function(event, ui) {
                let position = [(Math.floor(ui.position.left) - $(window).scrollLeft()),
                                 (Math.floor(ui.position.top) - $(window).scrollTop())];
                $(event.target).parent().css('position', 'fixed');
                $(dlg).dialog('option','position',position);
            },
            beforeClose: function() {
                // store the position of the dialog so we can reopen it there after page refresh
                let cachemap = new Map(JSON.parse(localStorage.getItem('floatcmt')));

                let pos = $(dlg).dialog( "option", "position" );
                pos = { my: pos.my, at: pos.at }; // need to keep only the pieces we need - it's a cyclic object!
                cachemap.set('pos', JSON.stringify(pos));

                // store the current settings along with it
                cachemap.set('quotes', $('#float_cmt_quote').val());
                cachemap.set('kbd', $('#float_cmt_kbd').val());
                bindShortcut($('#float_cmt_kbd').val()); // update the keyboard shortcut binding so it takes effect

                localStorage.setItem('floatcmt', JSON.stringify( Array.from(cachemap.entries()) ));

                // issue: when closing the dialog, the opening button is scrolled back into focus - intended behavior (:
                // workaround: remember the scroll position before closing and return there after
                scrollPOS = window.scrollY; // get current scroll position
            },
            close: function() {
                window.scroll({ top: scrollPOS, left: 0, behavior: "instant" }); // scroll page back to previous scroll position
            }
        });

        // load cache: [0] = text, [1] = quotes, [2] = kbd
        let cache = loadCache();

        $(dlg).html(`<div id="float_cmt_title" style="margin: 0 0 0.2em 0;">Comment as <span id="float_cmt_pseud"></span> on <span id="float_cmt_chap"></span></div>
                     <div id="float_cmt_userinput"><textarea style="min-height: 8em">${cache[0]}</textarea>
                     <div id="float_cmt_counter"><span>10000</span> characters left</div>
                     <div id="float_cmt_settings" style="display: none; margin: 0.5em 0 0 0;">
                     Quotes: <select id="float_cmt_quote"><option value="i" ${cache[1] == "i" ? "selected" : ""}>Italics</option>
                     <option value="q" ${cache[1] == "q" ? "selected" : ""}>Blockquote</option></select>
                         ${screen > 500 ? `Keyboard Shortcut: <input id="float_cmt_kbd" type="text" value="${cache[2]}">
                         <div id="float_cmt_settings_hint" style="display: none;" class="ui-state-highlight ui-corner-all">
                         Use any combination of Ctrl/Alt/Shift and a letter or number</div>` : `<input id="float_cmt_kbd" value="${cache[2]}" type="hidden">`}
                     </div></div>`);

        // add the pseud selection to the dialog so we know which one to submit with
        let pseud_id = $("#add_comment_placeholder [name='comment[pseud_id]']").get(0); // available pseuds - either a hidden <input>, or a <select>
        pseud_id = $(pseud_id).clone().attr('id', 'float_cmt_pseud_select'); // either way, cloning the field for our purposes
        $('#float_cmt_pseud').append(pseud_id); // adding it to the dialog
        if ($(pseud_id).prop('tagName') == "INPUT") { // if there are no pseuds to select, add the username
            $('#float_cmt_pseud').append($("#add_comment_placeholder span.byline").text());
        }

        // building a chapter selection list - we only use this to pick where to send the comment to
        let select_chapter = `<option value="/works/${workID}/comments" selected="selected">Entire Work</option>`;
        // build chapter options if there are any: <option value="/chapters/chapterID/comments">Chapter #</option>
        if ($(chapterIDs[0]).prop('tagName') == "A") {
            $(chapterIDs).each(function(i) {
                select_chapter += `<option value="${$(this).attr('href').match(/\/chapters\/\d+/i)[0]}/comments">${$(this).text()}</option>`;
            });
        }
        else if ($(chapterIDs[0]).prop('tagName') == "OPTION") {
            select_chapter = `<option value="/works/${workID}/comments">Entire Work</option>`; // reset to not-selected option
            $(chapterIDs).each(function(i) {
                select_chapter += `<option value="/chapters/${$(this).val()}/comments" ${$(this).prop('selected') ? "selected='selected'" : ""}>
                                   Chapter ${i+1}${$(this).prop('selected') ? " (viewing)" : ""}</option>`;
            });
        }
        select_chapter = "<select>" + select_chapter + "</select>";
        $('#float_cmt_chap').append(select_chapter);

        // check what's visible on page load (might be a refresh halfway down the page)
        if ($('#main ul.work.navigation li.chapter.bychapter').length > 0) whatsInView();

        // if there are no pseuds to select, and no chapters to select, save space and hide the whole part
        if (($(pseud_id).prop('tagName') == "INPUT") && $('#float_cmt_chap select option').length == 1) $('#float_cmt_title').hide();

        // listen to user typing so we can count characters and such
        $('#float_cmt_userinput textarea').on('input', function(e) {
            whenTextChanges(e.target);
        });

        // set the current keyboard shortcut binding
        bindShortcut(cache[2]);

        // in the settings field, let user set keyboard shortcut by pressing it
        $('#float_cmt_kbd').on('keydown', function(e) {
            e.preventDefault(); e.stopPropagation(); // this stops the browser from entering in the textfield or reacting for its own shortcuts

            // allow Backspace and Del key to reset to "" so shortcuts can be disabled
            if (e.key == "Backspace" || e.key == "Delete") {
                $('#float_cmt_settings_hint').hide();
                $('#float_cmt_kbd').val("");
            }
            // is this something we consider a valid option?
            if (e.key.length > 1 || e.key == " ") return; // only letters/numbers have a e.key string length of 1
            if (!e.ctrlKey && !e.altKey) { // don't even try if it isn't a combo using Ctrl or Alt
                $('#float_cmt_settings_hint').show();
                return;
            }

            // if it's good, build the text to show user what they selected
            $('#float_cmt_settings_hint').hide();
            let kbd = `${e.ctrlKey ? "Ctrl + " : ""}${e.altKey ? "Alt + " : ""}${e.shiftKey ? "Shift + " : ""}${e.key.toLowerCase()}`;
            $('#float_cmt_kbd').val(kbd);
        });
    }

    // bind the keyboard shortcut for toggling the dialog
    function bindShortcut(kbd) {
        $(window).off('keydown.floatcmt'); // start fresh or we're binding multiple listeners
        if (kbd == "") return; // if the shortcut was disabled, don't add any listeners
        kbd = kbd.split(" + "); // setting text split into chunks for easier comparison

        // listen to keypress if our shortcut was called (we're using the .floatcmt namespace for controlled on/off())
        $(window).on('keydown.floatcmt', function(e) {
            if (e.key.length > 1) return; // only letters/numbers have a e.key string length of 1
            if (!e.ctrlKey && !e.altKey) return; // don't even try if it isn't a combo using Ctrl or Alt
            //console.log(`${e.ctrlKey ? "Ctrl + " : ""}${e.altKey ? "Alt + " : ""}${e.shiftKey ? "Shift + " : ""}${e.key.toLowerCase()}`);

            // was this our shortcut?
            if (e.ctrlKey === kbd.includes("Ctrl") && e.altKey === kbd.includes("Alt") &&
                e.shiftKey === kbd.includes("Shift") && kbd.includes(e.key.toLowerCase())) {
                e.preventDefault(); e.stopPropagation(); // this stops the browser from reacting to its valid keyboard shortcuts (menu)
                toggleCommentBox();
            }
        });
    }

    // counter and cache: triggered by event and other functions when text in the commentbox changes
    function whenTextChanges(el) {
        // calculate remaining characters
        let cmt = $(el).val();
        let rem = 10000 - (cmt.length + cmt.split("\n").length-1); // count like AO3 does: linebreak = 2 chars
        $('#float_cmt_counter span').text(rem);

        // warning if we've exceeded allowed characters
        if (rem<0) $('#float_cmt_counter').addClass('ui-state-error ui-corner-all');
        else $('#float_cmt_counter').removeClass('ui-state-error ui-corner-all');

        storeCache();
    }

    // shows the dialog
    function openCommentBox() {
        $(dlg).dialog('open');

        // check if dialog opened off viewport (browser window now smaller) https://stackoverflow.com/a/7557433/22187458
        let rect = $(dlg).get(0).getBoundingClientRect();
        if (!(rect.top >= 0 && rect.left >= 0 &&
            rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
            rect.right <= (window.innerWidth || document.documentElement.clientWidth) )) {
            // then we reset to the default bottom right
            $(dlg).dialog('option','position', { my: "right bottom", at: "right bottom", of: window });
        }

        // setting the cursor at the end of the available text
        let area = $('#float_cmt_userinput textarea').get(0);
        area.focus();
        area.setSelectionRange(area.value.length, area.value.length);
    }

    // hides the dialog (more stuff is handled in the beforeClose and close dialog events)
    function closeCommentBox() {
        $(dlg).dialog('close');
    }

    // display or hide a few setting options within the dialog (below the textarea)
    function toggleSettings() {
        $('#float_cmt_settings').toggle();
    }

    // takes highlighted text and appends it to the comment
    function grabHighlight() {
        // copy highlighted text works only on summary, notes, and fic
        if ($(window.getSelection().anchorNode).parents(".userstuff").length > 0) {
            let area = $('#float_cmt_userinput textarea');
            let highlighted = $('#float_cmt_quote').val() == "i" ?
                `<i>${window.getSelection().toString().trim()}</i>` :
                `<blockquote>${window.getSelection().toString().trim()}</blockquote>`;

            $(area).val($(area).val() + highlighted); // insert new text at the end

            whenTextChanges(area); // trigger an update for the counter
        }
    }

    // update the stored cache (called on any text change)
    function storeCache() {
        let cachemap = new Map(JSON.parse(localStorage.getItem('floatcmt')));

        // cache is stored per page: workID -> text, workID-date -> last update date
        // update current values in Map() and localStorage immediately
        cachemap.set(workID, $('#float_cmt_userinput textarea').val()).set(workID+"-date", Date.now());
        localStorage.setItem('floatcmt', JSON.stringify( Array.from(cachemap.entries()) ));
    }

    // on page load, retrieve previously stored cached text and settings
    function loadCache() {
        let cachemap = new Map(JSON.parse(localStorage.getItem('floatcmt')));

        // squeezing in here logic to select the correct quotes & kbd shortcut setting
        let quotes = cachemap.get('quotes') || "";
        let kbd = cachemap.get('kbd') || "";

        // any cache outdated? we keep it for max 1 month to avoid storage limit issues
        let maxdate = createDate(0, -1, 0);
        cachemap.forEach((v, k) => {
            if (["quotes", "kbd", "pos"].includes(k)) return; // skip the non-comment parts
            if (k.endsWith("-date")) {
                let cachedate = new Date(v);
                if (cachedate < maxdate) {
                    cachemap.delete(k.slice(0, -5));
                    cachemap.delete(k);
                }
            }
            // delete any possible leftovers that don't have an associated date
            else if (cachemap.get(k+"-date") === undefined) cachemap.delete(k);
        });

        // cache is stored per page: workID -> text, workID-date -> last update date
        let cache = cachemap.get(workID) || ""; // blank if there's nothing stored yet for this workID

        return [cache, quotes, kbd];
    }

    // clean up cache for this page
    function deleteCache() {
        let cachemap = new Map(JSON.parse(localStorage.getItem('floatcmt')));

        // cache is stored per page: workID -> text, workID-date -> last update date
        cachemap.delete(workID);
        cachemap.delete(workID+'-date');

        localStorage.setItem('floatcmt', JSON.stringify( Array.from(cachemap.entries()) ));
    }

    // removes all traces of the comment for this page
    function discardComment() {
        $('#float_cmt_userinput textarea').val(""); // resets the textarea to blank
        whenTextChanges($('#float_cmt_userinput textarea')); // updates the counter accordingly
        deleteCache(); // deletes the cached data
        closeCommentBox(); // and hides the dialog
    }

    // assemble the form data needed to submit the comment
    function submitComment() {
        let pseud_id = $("#float_cmt_pseud_select").val(); // pick up the selected pseud (either hidden <input> or <select> option)
        let action = $("#float_cmt_chap select").val(); // per selection, the work or chapter target for submitting a comment

        // consolidating the fields we need for submitting a comment
        var fd = new FormData();
        fd.set("authenticity_token", $("#add_comment_placeholder input[name='authenticity_token']").val());
        fd.set("comment[pseud_id]", pseud_id);
        fd.set("comment[comment_content]", $(dlg).find('textarea').val());
        fd.set("controller_name", "works");

        console.log(action, fd);

        // turn buttons into a loading indicator
        let buttons = $(dlg).dialog( "option", "buttons" );
        $(dlg).dialog( "option", "buttons", [{
            text: "Posting Comment...",
            click: function() { return false; }
        }]);

        // post the comment and reload the page to show it
        grabResponse(action, fd, buttons);
    }

    // actually submit the comment in a POST request
    async function grabResponse(action, fd, buttons) {
        // post the comment! this uses the Fetch API to POST the form data
        const response = await fetch(action, { method: "POST", body: fd });

        // response might be not OK in case of retry later (427)
        if (!response.ok) {
            // show an error to the user
            $(dlg).dialog( "option", "buttons", [{
                text: "Error saving comment!",
                click: function() { return false; }
            }]);
            return false; // stop all processing (comment is still cached)
        }

        discardComment(); // clean up since it's now posted

        // question: did we post to a single chapter while viewing the entire work? then we probably want to keep on reading.
        // action tells us where we posted to, and if that was a /chapters/...
        // we can still tell that we were viewing an entire work by the available "Chapter by Chapter" button
        if (action.startsWith('/chapters/') && $('#main ul.work.navigation li.chapter.bychapter').length > 0) {
            $(dlg).dialog( "option", "buttons", buttons ); // reset the buttons in the dialog (which just said "Posting Comment..." now)
        }

        // otherwise we want to see the comment we just posted
        // eff this, there's no way to get the original redirected location of the POST (which includes the new #comment_id at the end)
        // so all we can do is look at the response page with comments shown (per the redirected GET)
        else {
            // puzzling together the reponse stream until we have a full HTML page (to avoid another background pageload)
            let responseBody = "";
            for await (const chunk of response.body) {
                let chunktext = new TextDecoder().decode(chunk); // turns it from uint8array to text
                responseBody += chunktext;
            }

            // find out if there's multiple pages of comments now, based on the comment pagination (pick the last page)
            let lastpage = $(responseBody).find('#comments_placeholder ol.pagination').first().children().eq(-2).find('a').attr('href');
            // if there's no pagination, just use the redirect URL; either way scroll that to the footer
            lastpage = (lastpage > "") ? lastpage.slice(0, -9)+'#footer' : response.url+'#footer';

            // redirect us to where we're hopefully seeing the comment we just posted
            window.location.href = lastpage;
        }
    }

})(jQuery);

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