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

当前为 2025-07-20 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         AO3 Floaty Comment Box (Responsive)
// @namespace    http://tampermonkey.net/
// @version      1.9
// @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.1em 0.4em;
            border-radius: 0.4em;
            font-family: inherit;
            font-size: 1.3em;
            color: inherit;
            opacity: .9;
            cursor: pointer;
            min-width: 25px;
        `;
        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 .5em;
            border-radius: 0.4em;
            font-family: inherit;
            font-size: .9em;
            color: inherit;
            opacity: .9;
            cursor: pointer;
            min-width: 25px;
        `;
        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: 30%;
            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: 0%;
            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;
            opacity: 1;
        `;

        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>Navigate:</strong> Scroll down the real comment box with the downward arrow and go back to your previous position with the upward arrow.</i>' +
            '<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>📞 <strong>Contact:</strong> <a href="mailto:[email protected]">Email me</a> for questions or issues.</p>` +
            `<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>`
        ;

        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>'
        ;

        const closeAboutBtn = document.createElement('button');
        const closeTipsBtn = document.createElement('button');
        [closeAboutBtn, closeTipsBtn].forEach(btn => {
            btn.innerHTML = 'x';
            btn.style.cssText = `
        position: absolute;
        top: .9em;
        right: .9em;
        cursor: pointer;
        `;
        });
        aboutDiv.appendChild(closeAboutBtn);
        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.3em;
            padding: .5em.9em;
            font-size: 1em;
            opacity: 1;
        `;

        const insertBtn = document.createElement('button');
        insertBtn.innerHTML = '&#171; quote &#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 upBtn = document.createElement('button');
        upBtn.innerHTML = '&uArr;';

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

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

        const collapseBtn = document.createElement('button');
        collapseBtn.innerHTML = '▼';

        const uncollapseBtn = document.createElement('button');
        uncollapseBtn.innerHTML = '▲';

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

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

        closeBtn.style.fontSize = '.8em';
        if (window.innerWidth > 768) {
            closeBtn.style.marginRight = '1.5em';
        }
        minimizeBtn.style.display = 'none';
        uncollapseBtn.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(collapseBtn, expandBtn, uncollapseBtn, minimizeBtn, closeBtn);

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

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

        // Character count
        const charCount = document.createElement('span');
        const updateCharCount = () => {
            let count = 10000 - floatyBox.value.length;
            charCount.textContent = `${count} characters left`;
            charCount.style.color = count < 0 ? 'red' : 'inherit';
        };
        updateCharCount();

        // Footer
        const footer = document.createElement('div');
        footer.id = 'floaty-footer';
        footer.style.cssText = `
            display: flex;
            justify-content: flex-end;
            align-items: center;
            align-content: stretch;
            gap: 0.5em;
            padding: .5em .9em;
            font-size: .8em;
            opacity: 1;
        `;
        footer.appendChild(charCount);
        floatyContainer.appendChild(footer);

        // 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(`<blockquote><em>${selected}</em></blockquote>\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';
            collapseBtn.style.display = 'none';
            uncollapseBtn.style.display = 'none';
            minimizeBtn.style.display = 'block';
            floatyContainer.style.height = '100%';
        });

        minimizeBtn.addEventListener('click', () => {
            minimizeBtn.style.display = 'none';
            uncollapseBtn.style.display = 'none';
            expandBtn.style.display = 'block';
            collapseBtn.style.display = 'block';
            floatyContainer.style.height = '30%';
        });

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

        uncollapseBtn.addEventListener('click', () => {
            uncollapseBtn.style.display = 'none';
            minimizeBtn.style.display = 'none';
            expandBtn.style.display = 'block';
            collapseBtn.style.display = 'block';
            floatyContainer.style.height = '30%';
        });

        downBtn.addEventListener('click', () => {
            // Save scroll position to local storage
            localStorage.setItem("scrollY",window.scrollY);

            // Jump to real comment box
            document.querySelector('textarea[name="comment[comment_content]"]').scrollIntoView();
        });

        upBtn.addEventListener('click', () => {
            // Go to previous scroll position
            const prevScrollPosition = localStorage.getItem("scrollY");
            window.scrollTo(0,parseInt(prevScrollPosition));
        });

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

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

    }

    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

})();