AO3: Floating Comment Box - Redux

my version of the floating comment box script by ScriptMouse

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

您需要先安装一个扩展,例如 篡改猴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 - Redux
// @namespace    https://greasyfork.org/en/users/906106-escctrl
// @version      0.4
// @description  my version of the floating comment box script by ScriptMouse
// @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';

    // done: get a floating comment box that is not modal (you can move it around) and resizable
    // GOAL: make it work on mobile XD
    // GOAL: make it work with my Comment Formatting script (that means adapting the other script)
    // done: make it submit the comment directly, no need to copy it elsewhere (like owl's comment from bins)
    // done: cache comment text
    // GOAL: choices of pseud and chapter (if viewing multiple chapters)
    // done: insert highlighted text directly in comment (in italics or blockquote)
    // done: character counter
    // done: open it from a nicely placed button
    // done: while submitting, show some sort of progress & load the page to the new comment if possible
    // done: make it open at the position where it was last closed

    // button at top of work to open the modal
    let cmtButton = `<li id='float_cmt_button'><a href='#'>Floating Comment</a></li>`;
    $('#show_comments_link_top').after(cmtButton);

    var dlg = "#float_cmt_dlg";

    // if the background is dark, use the dark UI theme to match
    let dialogtheme = lightOrDark($('body').css('background-color')) == "dark" ? "vader" : "base";
    $("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: 80%; }</style>`);

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

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

    // 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 size of the GUI in case it's a mobile device
        let dialogwidth = parseInt($("body").css("width")); // parseInt ignores letters (px)
        dialogwidth = dialogwidth > 500 ? 500 : dialogwidth * 0.9;

        $(dlg).dialog({
            modal: false,
            autoOpen: false,
            resizable: true,
            width: dialogwidth,
            position: { my: "right bottom", at: "right bottom" },
            title: "Comment",
            buttons: {
                CopyHighlight: grabHighlight,
                Discard: discardComment,
                Post: submitComment,
                Cancel: 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);
                }
            },
            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);
            }
        });

        $(dlg).html(`<div id="float_cmt_title" style="display: none;">Comment as [user] on [chapter]</div><div id="float_cmt_userinput">
                     <textarea style="min-height: 8em">${loadCache()}</textarea>
                     <div id="float_cmt_counter" style="font-size: 80%; padding: 0.2em; margin: 0.2em 0;"><span>10000</span> characters left</div></div>`);

        $('#float_cmt_userinput textarea').on('input', function(e) {
            whenTextChanges(e.target);
        });
    }

    // 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() {
        // use the position of the dialog
        $(dlg).dialog('open');
    }

    // 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));
        localStorage.setItem('floatcmt', JSON.stringify( Array.from(cachemap.entries()) ));

        $(dlg).dialog('close');
    }

    // 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 = `<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
    function loadCache() {
        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;

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

        if (!cache) return ""; // if there's nothing stored yet for this path
        else return cache;
    }

    // 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 = $("#add_comment_placeholder input[name='comment[pseud_id]']").val(); // need to pick up the selected pseud
        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'; }
}