AO3 Floaty Comment Box (Responsive)

AO3 Floaty Comment Box (Responsive) is a userscript created to facilitate commenting on the fly while reading on archiveofourown - specifically for mobile browsing

As of 2025-07-18. See the latest version.

// ==UserScript==
// @name         AO3 Floaty Comment Box (Responsive)
// @namespace    http://tampermonkey.net/
// @version      1.3
// @description  AO3 Floaty Comment Box (Responsive) is a userscript created to facilitate commenting on the fly while reading on archiveofourown - specifically for mobile browsing
// @author       Schildpath
// @match        http://archiveofourown.org/*
// @match        https://archiveofourown.org/*
// @match        http://www.archiveofourown.org/*
// @match        https://www.archiveofourown.org/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    console.log("AO3 Floaty Script Loaded");

    let floatyCreated = false;

    function createFloaty(commentBox) {

        // Prevent double initialization or initialization without cause
        if (floatyCreated || !commentBox) return;
        floatyCreated = true;

        // Floaty toggle button
        const floatyButton = document.createElement('button');
        floatyButton.innerHTML = '✍';
        floatyButton.id = 'floaty-toggle-button';
        floatyButton.setAttribute('aria-label', 'Toggle Comment Box');
        floatyButton.style.cssText = `
            position: fixed;
            inset: auto 1rem 1rem auto;
            z-index: 9999;
            padding: 0.2em 0.4em;
            border-radius: 0.4em;
            font-family: inherit;
            font-size: 1.3em;
            color: inherit;
            opacity: .9;
            cursor: pointer;
        `;
        floatyButton.onfocus = (e) => e.target.blur(); // Prevent AO3 focus style
        document.body.appendChild(floatyButton);

        // Floaty insert quote button
        const insertButton = document.createElement('button');
        insertButton.innerHTML = '« »';
        insertButton.id = 'floaty-insert-button';
        insertButton.setAttribute('aria-label', 'Insert Quote');
        insertButton.style.cssText = `
            position: fixed;
            inset: auto 1rem 3.7rem auto;
            z-index: 9999;
            padding: .4em;
            padding-bottom:.6em;
            border-radius: 0.4em;
            font-family: inherit;
            font-size: .9em;
            color: inherit;
            opacity: .9;
            cursor: pointer;
        `;
        insertButton.onfocus = (e) => e.target.blur(); // Prevent AO3 focus style
        document.body.appendChild(insertButton);

        // Floaty box container
        const floatyContainer = document.createElement('div');
        floatyContainer.id = 'floaty-container';
        floatyContainer.style.cssText = `
            position: fixed;
            bottom: 0;
            left: 0;
            right: 0;
            width: 100vw;
            height: 200px;
            z-index: 9998;
            display: none;
            flex-direction: column;
            background: inherit;
            border-top: 1px solid currentColor;
            font-family: inherit;
            font-size: 0.8em;
            color: inherit;
            opacity: .95;
        `;
        document.body.appendChild(floatyContainer);

        // Tips & about container
        const infoContainer = document.createElement('div');
        infoContainer.style.cssText = `
            position: fixed;
            bottom: 200px;
            left: 0;
            right: 0;
            z-index: 9997;
            display: none;
            flex-direction: column;
            padding: .9em;
            gap: 0.5em;
            font-family: inherit;
            background: inherit;
            border-top: 1px solid currentColor;
            border-bottom: 1px solid currentColor;
        `;

        const closeAboutBtn = document.createElement('button');
        closeAboutBtn.innerHTML = 'x';
        const aboutDiv = document.createElement('div');
        aboutDiv.innerHTML = `<p><strong>About AO3 Floaty Comment Box (Responsive):</strong></p>` +
            '<p>AO3 Floaty Comment Box (Responsive) was created to facilitate commenting while reading - to allow the user to copy paste favorite quotes and write down feelings & thoughts on the fly - specifically for mobile browsing.</p>' +
            '<ul><li>🔛 <strong>Toggle function:</strong> You can minimize the floaty comment box as you read and reopen it to continue to edit your review.</li>' +
            '<li>💬 <strong>Insert quotes:</strong> Select favorite quotes and use the &#171; &#187; button to insert them your comment: the selected text will be formatted in italics and put between quotation marks.</li>' +
            '<li>🔄 <strong>Syncing:</strong> Everything that is typed in the floaty comment box will be automatically synced with the real comment box below the fic.</i>' +
            '<li>💌 <strong>Submitting:</strong> Your comment will only be submitted once you submit it in the the real comment form below (scroll down with the &dArr; button).</li></ul>' +
            `<p>&#169; AO3 Floaty Comment Box (Responsive) was directly inspired (with permission) by an AO3 userscript originally developed by <a href="https://ravenel.tumblr.com/post/156555172141/i-saw-this-post-by-astropixie-about-how-itd-be">ravenel</a>.</p>`
        ;
        aboutDiv.appendChild(closeAboutBtn);

        const closeTipsBtn = document.createElement('button');
        closeTipsBtn .innerHTML = 'x';
        const tipsDiv = document.createElement('div');
        tipsDiv.innerHTML = `<p><strong>Suggestions for writing a comment:</strong></p>` +
            '<ul><li>💬 Quotes you liked (select text and click &#171; &#187; to include)</li>'+
            '<li>🎭 Scenes that you liked, or moved you, or surprised you</li>'+
            '<li>😭 What is your feeling at the end of the chapter?</li>'+
            '<li>👓 What are you most looking forward to next?</li>' +
            '<li>🔮 Do you have any predictions for the next chapters you want to share?</li>'+
            '<li>❓ Did this chapter give you any questions you can&#39;t wait to find out the answers for?</li>' +
            '<li>✨ Is there something unique about the story that you like?</li>'+
            '<li>🤹 Does the author have a style that really works for you?</li>' +
            '<li>🎤 Did the author leave any comments in the notes that said what they wanted feedback on?</li>' +
            '<li>🗣 Even if all you have are incoherent screams of delight, authors love to hear that as well.</li></ul>'
        ;
        tipsDiv.appendChild(closeTipsBtn);

        infoContainer.appendChild(aboutDiv);
        infoContainer.appendChild(tipsDiv);
        floatyContainer.appendChild(infoContainer);

        // Floaty container header
        const header = document.createElement('div');
        header.style.cssText = `
            display: flex;
            justify-content: flex-start;
            align-items: center;
            align-content: stretch;
            gap: 0.5em;
            padding: .5em.9em;
            font-size: 1em;
            opacity: 1;
        `;

        const logo = document.createElement('div');
        logo.innerHTML = '<span>&#9997;</span>';

        const insertBtn = document.createElement('button');
        insertBtn.innerHTML = '&#171; &#187;';

        const tipsBtn = document.createElement('button');
        tipsBtn.innerHTML = '&#128161;';

        const aboutBtn = document.createElement('button');
        aboutBtn.innerHTML = '❓';

        const downBtn = document.createElement('button');
        downBtn.innerHTML = '&dArr;';

        const expandBtn = document.createElement('button');
        expandBtn.innerHTML = '🗖';

        const minimizeBtn = document.createElement('button');
        minimizeBtn.innerHTML = '–';

        const closeBtn = document.createElement('button');
        closeBtn.innerHTML = '❯❯';

        [insertBtn, aboutBtn, tipsBtn, downBtn, expandBtn, minimizeBtn, closeBtn].forEach(btn => {
            btn.style.cssText = `
                font-family: inherit;
                color: inherit;
                cursor: pointer;
                padding: 0.2em 0.4em;
                font-size: 1em;
                min-height: 17px;
                min-width: 17px;
            `;
        });

        logo.style.cssText = `
        cursor: initial;
        font-size: 2em;
        padding: 0em 0.2em;
        `;

        closeBtn.style.fontSize = '.8em';
        if (window.innerWidth > 768) {
            closeBtn.style.marginRight = '1.5em';
        }
        minimizeBtn.style.display = 'none';

        const rightButtons = document.createElement('div');
        rightButtons.style.cssText = `
    margin-left: auto;
    display: flex;
    gap: 0.3em;
    justify-content: flex-start;
    align-items: center;
    align-content: stretch;
`;
        rightButtons.append(expandBtn, minimizeBtn, closeBtn);

        header.append(logo, insertBtn, tipsBtn, aboutBtn, downBtn, rightButtons);
        floatyContainer.appendChild(header);

        // Textarea
        const floatyBox = document.createElement('textarea');
        floatyBox.placeholder = 'Work-in-progress review...';
        floatyBox.id = 'floaty-box';
        floatyBox.style.cssText = `
            flex: 1;
            padding: .9em;
            font-size: 1em;
            font-family: inherit;
            color: inherit;
            background: inherit;
            border: none;
            resize: none;
            outline: none;
            width: 100%;
            box-sizing: border-box;
        `;
        floatyContainer.appendChild(floatyBox);

        // Button actions
        floatyButton.addEventListener('click', () => {
            floatyContainer.style.display = 'flex';
            floatyButton.style.display = 'none';
            insertButton.style.display = 'none';
        });

        closeBtn.addEventListener('click', () => {
            floatyContainer.style.display = 'none';
            floatyButton.style.display = 'block';
            insertButton.style.display = 'block';
            aboutDiv.style.display = 'none';
            tipsDiv.style.display = 'none';
        });

        [insertBtn, insertButton].forEach(btn => {
            btn.addEventListener('click', () => {
                const selected = window.getSelection().toString().trim();
                if (selected) {
                    floatyBox.focus();
                    floatyBox.setRangeText(`<i>"${selected}"</i>\n`, floatyBox.selectionStart, floatyBox.selectionEnd, 'end');
                    floatyBox.dispatchEvent(new Event('input')); // Sync with real box
                }
            });
        });

        aboutBtn.addEventListener('click', () => {
            infoContainer.style.display = infoContainer.style.display === 'none' ? 'flex' : 'none';
            aboutDiv.style.display = 'block';
            tipsDiv.style.display = 'none';
        });
        
        closeAboutBtn.addEventListener('click', () => {
            infoContainer.style.display = 'none';
            aboutDiv.style.display = 'none';
        });

        tipsBtn.addEventListener('click', () => {
            infoContainer.style.display = infoContainer.style.display === 'none' ? 'flex' : 'none';
            tipsDiv.style.display = 'block';
            aboutDiv.style.display = 'none';
        });
        
        closeTipsBtn.addEventListener('click', () => {
            infoContainer.style.display = 'none';
            tipsDiv.style.display = 'none';
        });

        expandBtn.addEventListener('click', () => {
            expandBtn.style.display = 'none';
            minimizeBtn.style.display = 'block';
            floatyContainer.style.height = '500px';
        });

        minimizeBtn.addEventListener('click', () => {
            minimizeBtn.style.display = 'none';
            expandBtn.style.display = 'block';
            floatyContainer.style.height = '200px';
        });

        downBtn.addEventListener('click', () => {
            document.querySelector('textarea[name="comment[comment_content]"]').scrollIntoView();
        });

        // Sync between floaty and real comment box
        floatyBox.addEventListener('input', () => {
            commentBox.value = floatyBox.value;
        });

        commentBox.addEventListener('input', () => {
            if (commentBox.value !== floatyBox.value) {
                floatyBox.value = commentBox.value;
            }
        });

        // [Pending issues]
        // Prevent unwanted scrolling
        floatyBox.addEventListener('focus', (e) => {
            // Prevent mobile "scroll-jump"
            requestAnimationFrame(() => {
                floatyBox.scrollIntoView({ block: 'nearest', behavior: 'instant' });
                // fallback in case `preventScroll` isn’t supported
                window.scrollTo(window.scrollX, window.scrollY);
            });
        });
    };

    function waitForCommentBox(attempts = 0) {
        // This script only applies to story pages where you can comment, which we need to check for
        const box = document.querySelector('textarea[name="comment[comment_content]"]');
        if (box) {
            createFloaty(box);
        } else if (attempts < 20) {
            setTimeout(() => waitForCommentBox(attempts + 1), 500);
        } else {
            console.log("Comment box not found after 20 attempts.");
        }
    }

    function init() {
        console.log("Init called");
        waitForCommentBox();
    }

    window.addEventListener('load', init);
    document.addEventListener('pjax:end', init);
    setInterval(() => {
        if (!floatyCreated) init();
    }, 1500); // Safety net for mobile load quirks

})();