// ==UserScript==
// @name AO3: Sticky Comment Box
// @namespace https://greasyfork.org/en/users/906106-escctrl
// @version 2.3
// @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
// @downloadURL https://update.greasyfork.icu/scripts/489335/AO3%3A%20Sticky%20Comment%20Box.user.js
// @updateURL https://update.greasyfork.icu/scripts/489335/AO3%3A%20Sticky%20Comment%20Box.meta.js
// ==/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 = `
`;
$('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(``)
.append(``);
// prepping the dialog (without opening it)
createCommentBox();
// prepares the dialog and loads the cache into it
function createCommentBox() {
// designing the floating box
$("body").append(``);
// 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");
},
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 and guest data (if exists) along with it
cachemap.set('quotes', $('#float_cmt_quote').val());
cachemap.set('kbd', $('#float_cmt_kbd').val());
if ($('#float_cmt_name').length > 0) cachemap.set('name', $('#float_cmt_name').val());
if ($('#float_cmt_email').length > 0) cachemap.set('email', $('#float_cmt_email').val());
bindShortcut($('#float_cmt_kbd').val()); // update the keyboard shortcut binding so it takes effect
localStorage.setItem('floatcmt', JSON.stringify( Array.from(cachemap.entries()) ));
},
close: function() {
// remove formatting buttonbar event listener while dialog is closed so we don't stack listeners each time the dialog is reopened
if ($(dlg).find('.comment-format').length > 0) $(dlg).find('.comment-format a').off("click.cmtfmt");
}
});
// load cache: [0] = text, [1] = quotes, [2] = kbd, [3] = name, [4] = email
let cache = loadCache();
$(dlg).html(`
Comment as on
10000 characters left
Quotes:
${screen > 500 ? `Keyboard Shortcut:
Use any combination of Ctrl/Alt/Shift and a letter or number
` : ``}
`);
// if we're logged in, add the pseud selection to the dialog so we know which one to submit with
if ($("#add_comment_placeholder [name='comment[pseud_id]']").length == 1) {
// clone the pseuds - either a hidden , or a