// ==UserScript==
// @name AO3: Floating Comment Box - Redux
// @namespace https://gf.qytechs.cn/en/users/906106-escctrl
// @version 0.2
// @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
$("head").append(`<link rel="stylesheet" href="https://code.jquery.com/ui/1.13.2/themes/base/jquery-ui.css">`)
// 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);
// prepping the dialog (without opening it)
var dlg = "#float_cmt_dlg";
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;
}