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 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         AO3 Floaty Comment Box (Responsive)
// @namespace    http://tampermonkey.net/
// @version      1.9.4
// @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';

    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;
            top: 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;
        `;
        document.body.appendChild(floatyContainer);

        // Tips & about container
        const infoContainer = document.createElement('div');
        infoContainer.style.cssText = `
            position: fixed;
            display: flex;
            top: 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;
        `;

        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

})();