AO3: Floating Comment Box

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

当前为 2024-03-09 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         AO3: Floating Comment Box
// @namespace    https://greasyfork.org/en/users/906106-escctrl
// @version      0.13
// @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/*
// @exclude      *://archiveofourown.org/works/*/new
// @exclude      *://archiveofourown.org/works/*/edit*
// @exclude      *://archiveofourown.org/works/new*
// @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';

    let cmtButton = `<div id="float_cmt_toggle"><button>Floating<br />Comment</button></div>`;
    $('body').append(cmtButton);

    // open or reopen the dialog when the button is clicked
    $('#float_cmt_toggle').on('click', (e) => {
        toggleCommentBox();
    });

    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; } #float_cmt_toggle button { height: unset; font-size: ${fontsize}; }</style>`);

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

    // 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" },
            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);
            },
        });

        // 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></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>
                     Keyboard Shortcut: <input id="float_cmt_kbd" 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>
                     </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, build up the proper HTML, but save space and hide the whole line
            $('#float_cmt_pseud').append($("#add_comment_placeholder span.byline").text());
            $('#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 from the browser 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');
        // 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
    function closeCommentBox() {
        // 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
        let scrollPOS = window.scrollY; // get current scroll position
        $(dlg).dialog('close');
        window.scroll({ top: scrollPOS, left: 0, behavior: "instant" }); // scroll page back to previous scroll position
    }

    // display or hide a few setting options within the dialog (below the textarea)
    function toggleSettings() {
        // showing/hiding the bar
        $('#float_cmt_settings').toggle();
        // storing the settings for next time
    }

    // 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: path -> text, path-date -> last update date
        let path = new URL(window.location.href).pathname;

        // update current values in Map() and localStorage immediately
        cachemap.set(path, $('#float_cmt_userinput textarea').val()).set(path+"-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 setting
        let quotes = cachemap.get('quotes') || "";
        let kbd = cachemap.get('kbd') || "";

        // cache is stored per page: path -> text, path-date -> last update date
        let path = new URL(window.location.href).pathname;

        // is cache outdated? we keep it for 1 month to avoid storage limit issues
        let cachedate = new Date(cachemap.get(path+"-date") || '1970');
        let maxdate = createDate(0, -1, 0);
        if (cachedate < maxdate) deleteCache(path);

        let cache = cachemap.get(path) || ""; // blank if there's nothing stored yet for this path

        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: path -> text, path-date -> last update date
        let path = new URL(window.location.href).pathname;
        cachemap.delete(path);
        cachemap.delete(path+'-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 = $("#add_comment_placeholder form").attr("action"); // already contains work ID

        // 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
        $(dlg).dialog( "option", "buttons", [{
            text: "Posting Comment...",
            click: function() { return false; }
        }]);

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

    // actually submit the comment in a POST request
    async function grabResponse(action, fd) {
        // 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)
        }

        // 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)

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

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

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