您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
AO3 Floaty Comment Box (Responsive) is a userscript created to facilitate commenting on the fly while reading on archiveofourown - specifically for mobile browsing
// ==UserScript== // @name AO3 Floaty Comment Box (Responsive) // @namespace http://tampermonkey.net/ // @version 1.12.5 // @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'; // Constants // ------------------------------------------------------------ const floatyId = 'floaty-box'; const quoteSetting = 'floaty-quote-format'; const viewSetting = 'floaty-default-view'; const storyKey = 'floaty-draft' + location.pathname; const quoteFormats = { "block": (text) => `<blockquote><em>${text.trim()}</em></blockquote>\n`, "inline": (text) => `<em>"${text.trim()}"</em>` }; // Helper functions // ------------------------------------------------------------ function getSetting(key, defaultValue) { return localStorage.getItem(key) ?? defaultValue; } function setSetting(key, value) { localStorage.setItem(key, value); } let scrollY = 0; function lockScroll() { scrollY = window.scrollY; document.body.style.position = 'fixed'; document.body.style.top = `-${scrollY}px`; document.body.style.left = '0'; document.body.style.right = '0'; document.body.style.overflow = 'hidden'; document.body.style.width = '100%'; document.documentElement.style.overflow = 'hidden'; // <html> } function unlockScroll() { document.body.style.position = ''; document.body.style.top = ''; document.body.style.left = ''; document.body.style.right = ''; document.body.style.overflow = ''; document.body.style.width = ''; document.documentElement.style.overflow = ''; window.scrollTo(0, scrollY); } function isIOSSafari() { return ( /iP(ad|hone|od)/.test(navigator.userAgent) && /Safari/.test(navigator.userAgent) && !/CriOS|FxiOS|OPiOS|EdgiOS/.test(navigator.userAgent) ); } if (isIOSSafari()) { document.documentElement.classList.add('ios-safari'); } // Create floaty box // ------------------------------------------------------------ function createFloaty(commentBox) { // If floaty already exists, exit if (document.getElementById(floatyId)) return; // Global css/style // ------------------------------------------------------------ const style = document.createElement('style'); style.textContent = ` #floaty-box-container { position: fixed; top: 0; left: 0; right: 0; width: 100vw; height: 30%; z-index: 9997; display: none; flex-direction: column; background: inherit; font-family: inherit; font-size: 0.8em; color: inherit; box-shadow: 0 5px 10px rgba(0,0,0,0.5); overflow:hidden; } #floaty-header { display: flex; justify-content: flex-start; align-items: center; align-content: stretch; gap: 0.3em; padding: .5em.9em; font-size: 1em; } #floaty-textarea { padding: .9em; font-size: 1em; font-family: inherit; color: inherit; background: inherit; border: none; resize: none; outline: none; width: 100%; flex: 1; min-height: 0; box-sizing: border-box; } .ios-safari #floaty-textarea { font-size: 16px; } #floaty-toggle { position: fixed; inset: .5em .5em auto 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: 20px; min-height: 20px; } #floaty-bg-container { position: fixed; top: 0; left: 0; background-color: rgba(0, 0, 0, 0.5); width: 100vw; height: 100vh; display: none; z-index: 9998 } #floaty-popup-container { position: fixed; display: flex; top: 50%; left: 0; transform: translate(0,-50%); margin: 0 1em; z-index: 9999; display: none; flex-direction: column; padding: .9em; gap: 0.5em; font-family: inherit; font-size: 0.8em; background: inherit; overflow-y: auto; border-top: 1px solid inherit; border-bottom: 1px solid inherit; box-shadow: 0 0 10px rgba(0,0,0,0.3); } #floaty-tips, #floaty-settings { display: none; max-height: 70vh; overflow-y: auto; } .floaty-btn { font-family: inherit; color: inherit; cursor: pointer; padding: 0.2em 0.4em; font-size: 1em; height: 17px; min-width: 17px; } .floaty-exit-btn { position: absolute; top: .9em; right: .9em; cursor: pointer; } #floaty-header-right { margin-left: auto; display: flex; gap: 0.3em; justify-content: flex-start; align-items: center; align-content: stretch; } #floaty-footer { display: flex; justify-content: flex-end; align-items: center; align-content: stretch; gap: 0.5em; padding: .5em .9em; font-size: .8em; opacity: 1; } fieldset { border: none; box-shadow: none; padding: 0; padding-left: 1em; margin: 0; } #floaty-settings li, #floaty-settings hr, #floaty-settings p { padding: 0.3em 0; } `; document.head.appendChild(style); // Elements // ------------------------------------------------------------ // TOGGLE BUTTON const toggleButton = document.createElement('button'); toggleButton.id = 'floaty-toggle'; toggleButton.innerHTML = '✍'; toggleButton.setAttribute('aria-label', 'Toggle Floaty Comment Box'); toggleButton.onclick = () => { // Shows the floaty box when clicked showFloaty(); }; // CONTAINER const container = document.createElement('div'); container.id = 'floaty-box-container'; // HEADER const header = document.createElement('div'); header.id = 'floaty-header'; header.onclick = (e) => { // Collapse/expand on header click if (e.target === header) { textarea.style.display === 'none' ? uncollapseBtn.click() : collapseBtn.click(); } }; // TEXTAREA const textarea = document.createElement('textarea'); textarea.id = 'floaty-textarea'; textarea.placeholder = 'Work-in-progress review...'; // FOOTER const footer = document.createElement('div'); footer.id = 'floaty-footer'; // CHARACTER COUNT const charCount = document.createElement('span'); charCount.id = 'floaty-char-count'; updateCharCount(); // CLEAR BUTTON const clearBtn = document.createElement('button'); clearBtn.innerHTML = '🗑️'; clearBtn.setAttribute('aria-label', 'Clear Comment Box'); clearBtn.classList.add('floaty-btn'); clearBtn.onclick = () => { if (textarea.value.trim() === '') return; // Do nothing if already empty const confirmed = window.confirm('Are you sure you want to clear the comment box? Your draft will be lost.'); if (confirmed) { textarea.value = ''; updateCharCount(); } }; // POP UP CONTAINER const popupContainer = document.createElement('div'); popupContainer.id = 'floaty-popup-container'; popupContainer.onclick = (e) => { // Prevent click events from propagating to the background container e.stopPropagation(); } // BG FOR POP UP CONTAINER const bgContainer = document.createElement('div'); bgContainer.id = 'floaty-bg-container'; bgContainer.onclick = () => { // Hide the popup and background when clicking outside closePopup(); }; // TIPS const tipsContainer = document.createElement('div'); tipsContainer.id = 'floaty-tips'; tipsContainer.innerHTML = `<h3>Suggestions for writing a comment:</h3>` + '<ul><li>💬 Quotes you liked (select text and click « » 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?</li>' + '<li>❓ Did this chapter give you any questions you can't wait to find out the answers to?</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, go for it.</li></ul>' ; // SETTINGS / ABOUT const settingsContainer = document.createElement('div'); settingsContainer.id = 'floaty-settings'; settingsContainer.innerHTML = ` <h3>About</h3> <p>AO3 Floaty Comment Box (Responsive) facilitates commenting while reading. It allows the user to copy paste favorite quotes, and write down feelings & thoughts on the fly.</p> <hr> <ul> <li>🔛 <strong>View:</strong> Toggle the floaty box on and off, or expand/collapse the box with the triangle buttons (▼▲) in the header bar. <fieldset> <label><input type="radio" name="default-view" value="toggle-button" checked> Toggle button visible by default</label><br> <label><input type="radio" name="default-view" value="header-bar"> Header bar visible by default</label> </fieldset> </li> <li>💬 <strong>Insert quotes:</strong> Select favorite quotes and use the « » button to insert them your comment. Choose how inserted text will be formatted: <fieldset> <label><input type="radio" name="quote-format" value="block" checked> Blockquote + italics</label><br> <label><input type="radio" name="quote-format" value="inline"> Inline + italics + quotation marks</label> </fieldset> </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 (⇑).</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. Drafts will be remembered until the review is submitted.</li> <li>💌 <strong>Submitting:</strong> Your comment will only be submitted once you submit it in the the real comment form below.</li> </ul> <hr> <p>📞 <strong>Contact:</strong> <a href="https://greasyfork.org/en/scripts/542872-ao3-floaty-comment-box-responsive">Give feedback on GreasyFork</a> for questions or issues.</p> <p>© 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>. See additional credit on GreasyFork.</p> `; settingsContainer.querySelectorAll('input[name="quote-format"]').forEach(radio => { radio.checked = (radio.value === getSetting(quoteSetting, 'block')); }); settingsContainer.querySelectorAll('input[name="default-view"]').forEach(radio => { radio.checked = (radio.value === getSetting(viewSetting, 'toggle-button')); }); settingsContainer.querySelectorAll('input[type="radio"]').forEach(input => { input.addEventListener('change', (e) => { if (e.target.name === 'default-view') { setSetting(viewSetting, e.target.value); } else if (e.target.name === 'quote-format') { setSetting(quoteSetting, e.target.value); } }); }); // EXIT BUTTONS FOR POPUPS const exitTipsBtn = document.createElement('button'); const exitSettingsBtn = document.createElement('button'); [exitTipsBtn, exitSettingsBtn].forEach(btn => { btn.innerHTML = 'x'; btn.className = 'floaty-exit-btn'; btn.setAttribute('aria-label', 'Close Popup'); }); [exitSettingsBtn, exitTipsBtn].forEach(btn => { btn.onclick = () => closePopup(); }); // INSERT QUOTE BUTTON const insertBtn = document.createElement('button'); if (window.innerWidth < 350) { insertBtn.innerHTML = '« »'; // Shorten for small screens } else { insertBtn.innerHTML = '« quote »'; // Full text for larger screens } // Insert quote logic let lastSelectedText = ''; document.addEventListener('selectionchange', () => { // Save last selection, to make sure it's remembered even if IOS loses it lastSelectedText = window.getSelection().toString().trim(); }); insertBtn.addEventListener('click', (e) => { (e).preventDefault(); // Gets selected text, formats it, and inserts it into the textarea const selected = lastSelectedText; if (selected) { const format = getSetting(quoteSetting, 'block'); // Default formatting is blockquote const formattedQuote = quoteFormats[format](selected) || quoteFormats['block'](selected); textarea.value += formattedQuote; commentBox.value = textarea.value; // Sync with real box } updateCharCount(); saveToLocalStorage(textarea.value); if (textarea.style.display != 'none') textarea.focus(); }); insertBtn.setAttribute('aria-label', 'Insert Quote'); // DOWN BUTTON const downBtn = document.createElement('button'); downBtn.innerHTML = '⇓'; downBtn.onclick = () => { // Scrolls down to the real comment box localStorage.setItem("floaty-scrollY", window.scrollY); // Save scroll position to local storage document.querySelector('textarea[name="comment[comment_content]"]').scrollIntoView(); }; downBtn.setAttribute('aria-label', 'Scroll Down to Comment Box'); // UP BUTTON const upBtn = document.createElement('button'); upBtn.innerHTML = '⇑'; upBtn.onclick = () => { // Scrolls back to the previous position const prevScrollPosition = localStorage.getItem("floaty-scrollY"); if (prevScrollPosition) { window.scrollTo(0, parseInt(prevScrollPosition)); } }; upBtn.setAttribute('aria-label', 'Scroll Up to Previous Position'); // TIPS BUTTON const tipsBtn = document.createElement('button'); tipsBtn.innerHTML = '💡'; tipsBtn.onclick = () => showPopup('tips'); tipsBtn.setAttribute('aria-label', 'Show Tips for Writing Comments'); // SETTINGS / ABOUT BUTTON const settingsBtn = document.createElement('button'); settingsBtn.innerHTML = '⚙️'; settingsBtn.onclick = () => showPopup('settings'); settingsBtn.setAttribute('aria-label', 'Show About and Settings'); // RIGHT SIDE OF HEADER const headerRight = document.createElement('div'); headerRight.id = 'floaty-header-right'; // EXPAND BUTTON const expandBtn = document.createElement('button'); expandBtn.innerHTML = '▼'; expandBtn.onclick = () => showExpandedView(); expandBtn.setAttribute('aria-label', 'Expand Comment Box to Full Height'); // MINIMIZE BUTTON const minimizeBtn = document.createElement('button'); minimizeBtn.innerHTML = '▲'; minimizeBtn.onclick = () => showFloaty(); minimizeBtn.setAttribute('aria-label', 'Minimize Comment Box'); // COLLAPSE BUTTON const collapseBtn = document.createElement('button'); collapseBtn.innerHTML = '▲'; collapseBtn.onclick = () => showCollapsedView(); collapseBtn.setAttribute('aria-label', 'Collapse Comment Box'); // UNCOLLAPSE BUTTON const uncollapseBtn = document.createElement('button'); uncollapseBtn.innerHTML = '▼'; uncollapseBtn.onclick = () => showFloaty(); uncollapseBtn.setAttribute('aria-label', 'Uncollapse Comment Box'); // CLOSE BUTTON const closeBtn = document.createElement('button'); closeBtn.innerHTML = '❯❯'; closeBtn.onclick = () => showToggleButton(); closeBtn.setAttribute('aria-label', 'Close Comment Box'); [insertBtn, downBtn, upBtn, tipsBtn, settingsBtn, expandBtn, minimizeBtn, collapseBtn, uncollapseBtn, closeBtn].forEach(btn => { btn.className = 'floaty-btn'; }); [minimizeBtn, uncollapseBtn].forEach(btn => { btn.style.display = 'none'; // Hide these by default }); closeBtn.style.fontSize = '.8em'; if (window.innerWidth > 768) { closeBtn.style.marginRight = '1.5em'; } if (getSetting(viewSetting, 'toggle-button') === 'header-bar') { showCollapsedView(); } else { showToggleButton(); } header.append(insertBtn, downBtn, upBtn, tipsBtn, settingsBtn) headerRight.append(expandBtn, minimizeBtn, collapseBtn, uncollapseBtn, closeBtn); header.appendChild(headerRight); container.appendChild(header); container.appendChild(textarea); footer.appendChild(charCount); footer.appendChild(clearBtn); container.appendChild(footer); tipsContainer.appendChild(exitTipsBtn); settingsContainer.appendChild(exitSettingsBtn); popupContainer.appendChild(settingsContainer); popupContainer.appendChild(tipsContainer); document.body.appendChild(popupContainer); document.body.appendChild(bgContainer); document.body.appendChild(toggleButton); document.body.appendChild(container); // Helper functions for creating the floaty box // ------------------------------------------------------------ function showToggleButton() { // Hide the floaty box & header, only show toggle button container.style.display = 'none'; toggleButton.style.display = 'block'; popupContainer.style.display = 'none'; bgContainer.style.display = 'none'; } function showFloaty() { // Minimize textarea to a default height container.style.display = 'flex'; toggleButton.style.display = 'none'; container.style.height = '30%'; textarea.style.display = 'block'; footer.style.display = 'flex'; minimizeBtn.style.display = 'none'; uncollapseBtn.style.display = 'none'; expandBtn.style.display = 'block'; collapseBtn.style.display = 'block'; } function showCollapsedView() { // Collapse textarea so only header remains container.style.display = 'flex'; container.style.height = '36px'; // Height of the header header.style.display = 'flex'; textarea.style.display = 'none'; footer.style.display = 'none'; toggleButton.style.display = 'none'; uncollapseBtn.style.display = 'block'; minimizeBtn.style.display = 'none'; expandBtn.style.display = 'none'; collapseBtn.style.display = 'none'; } function showExpandedView() { // Expand textarea to full height container.style.height = '100vh'; uncollapseBtn.style.display = 'none'; minimizeBtn.style.display = 'block'; expandBtn.style.display = 'none'; collapseBtn.style.display = 'none'; textarea.style.display = 'block'; footer.style.display = 'flex'; } function showPopup(popupType) { bgContainer.style.display = 'block'; popupContainer.style.display = 'flex'; document.body.style.overflow = 'hidden'; if (popupType === 'settings') { settingsContainer.style.display = 'block'; tipsContainer.style.display = 'none'; } else if (popupType === 'tips') { tipsContainer.style.display = 'block'; settingsContainer.style.display = 'none'; } } function closePopup() { bgContainer.style.display = 'none'; popupContainer.style.display = 'none'; tipsContainer.style.display = 'none'; settingsContainer.style.display = 'none'; document.body.style.overflow = ''; } function updateCharCount() { const count = 10000 - textarea.value.length; charCount.textContent = `${count} characters left`; charCount.style.color = count < 0 ? 'red' : 'inherit'; } // Syncing actions // ------------------------------------------------------------ // Sync between floaty and real comment box textarea.addEventListener('input', () => { commentBox.value = textarea.value; updateCharCount(); saveToLocalStorage(textarea.value); }); commentBox.addEventListener('input', () => { if (commentBox.value !== textarea.value) { textarea.value = commentBox.value; updateCharCount(); saveToLocalStorage(textarea.value); } }); // Check whether there is a saved draft in local storage const savedDraft = localStorage.getItem(storyKey); if (savedDraft) { textarea.value = savedDraft; commentBox.value = savedDraft; updateCharCount(); } // Automatically save comment draft to local storage let save; function saveToLocalStorage(value) { clearTimeout(save); save = setTimeout(() => { localStorage.setItem(storyKey, value); }, 500); // Saves to localstorage with .5s delay } // Detect when the comment form is submitted const commentForm = document.querySelector('form.new_comment, form.edit_comment'); if (commentForm) { commentForm.addEventListener('submit', () => { if (textarea) textarea.value = ''; // Clear floaty box localStorage.removeItem(storyKey); // Clear the saved draft }); } // Clear local storage draft after being cleared setInterval(() => { if (textarea.value === '' && commentBox.value === '') { localStorage.removeItem(storyKey); } }, 1000); // Unfocus the textarea on touch/scroll outside document.addEventListener('touchstart', (e) => { if ( document.activeElement === textarea && !container.contains(e.target) ) { textarea.blur(); } }, { passive: true }); } function waitForCommentBox() { // 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 { // Observe the document for changes to find the comment box const observer = new MutationObserver(() => { const found = document.querySelector('textarea[id^="comment_content"]'); if (found) { observer.disconnect(); createFloaty(found); } }); observer.observe(document.body, { childList: true, subtree: true }); } } if (document.readyState !== 'loading') { waitForCommentBox(); } else { window.addEventListener('DOMContentLoaded', waitForCommentBox); } })();