Greasy Fork

Greasy Fork is available in English.

AO3: Sticky Comment Box

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         AO3: Sticky Comment Box
// @namespace    http://greasyfork.icu/en/users/906106-escctrl
// @version      1.2
// @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*
// @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.works-show #chapters').length == 0) return;

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