// ==UserScript==
// @name AO3 Floaty Comment Box (Responsive)
// @namespace http://tampermonkey.net/
// @version 1.11
// @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: .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;
`;
floatyButton.onfocus = (e) => e.target.blur(); // Prevent AO3 focus style
document.body.appendChild(floatyButton);
// Floaty 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: 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;
`;
document.body.appendChild(floatyContainer);
// Tips & about
// ---------------------------------------
const bgContainer = document.createElement('div');
bgContainer.style.cssText = `
position: fixed;
top: 0;
left: 0;
background-color: rgba(0, 0, 0, 0.5);
width: 100vw;
height: 100vh;
display: none;
z-index: 9998
`;
const infoContainer = document.createElement('div');
infoContainer.style.cssText = `
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);
`;
const aboutDiv = document.createElement('div');
aboutDiv.innerHTML = `<p><strong>About:</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>Display:</strong> You can minimize the floaty comment box as you read and reopen it to continue to edit your review. You can also expand or collapse the box with the triangle buttons (▼▲) in the header bar.</li>' +
'<li>💬 <strong>Insert quotes:</strong> Select favorite quotes and use the « » button to insert them your comment: the selected text will be formatted in italics and put in a blockquote.</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 ⇓ button).</li></ul>' +
`<br><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>` +
`<br><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>. There is also a similar script by <a href="https://greasyfork.org/en/scripts/395902-ao3-floating-comment-box">ScriptMouse</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 « » 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>'
;
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);
document.body.appendChild(bgContainer);
document.body.appendChild(infoContainer);
// 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 = '« quote »';
const tipsBtn = document.createElement('button');
tipsBtn.innerHTML = '💡';
const aboutBtn = document.createElement('button');
aboutBtn.innerHTML = '❓';
const downBtn = document.createElement('button');
downBtn.innerHTML = '⇓';
const upBtn = document.createElement('button');
upBtn.innerHTML = '⇑';
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%;
flex: 1;
min-height: 0;
box-sizing: border-box;
`;
// 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();
// Check whether there is a saved draft in local storage
const storyKey = `review-draft${location.pathname}`; // Story & chapter specific
const savedDraft = localStorage.getItem(storyKey);
if (savedDraft) {
floatyBox.value = savedDraft;
commentBox.value = savedDraft;
updateCharCount();
}
floatyContainer.appendChild(floatyBox);
// 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);
// Actions
// ------------------------------------------------------
// Opens the floaty comment box
floatyButton.addEventListener('click', () => {
floatyContainer.style.display = 'flex';
floatyButton.style.display = 'none';
});
// Hides the floaty comment box
closeBtn.addEventListener('click', () => {
floatyContainer.style.display = 'none';
floatyButton.style.display = 'block';
aboutDiv.style.display = 'none';
tipsDiv.style.display = 'none';
});
// Inserts selected quote with blockquote/italic formatting and a new line to make sure
[insertBtn].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
}
});
});
// Shows about
aboutBtn.addEventListener('click', () => {
bgContainer.style.display = 'block';
infoContainer.style.display = 'flex';
aboutDiv.style.display = 'block';
tipsDiv.style.display = 'none';
});
// Closes about
closeAboutBtn.addEventListener('click', () => {
bgContainer.style.display = 'none';
infoContainer.style.display = 'none';
aboutDiv.style.display = 'none';
});
// Shows the tips
tipsBtn.addEventListener('click', () => {
bgContainer.style.display = 'block';
infoContainer.style.display = 'flex';
tipsDiv.style.display = 'block';
aboutDiv.style.display = 'none';
});
// Closes the tips
closeTipsBtn.addEventListener('click', () => {
bgContainer.style.display = 'none';
infoContainer.style.display = 'none';
tipsDiv.style.display = 'none';
});
// Clicking outside the tips/about container closes them again
bgContainer.addEventListener('click', () => {
bgContainer.style.display = 'none';
infoContainer.style.display = 'none';
aboutDiv.style.display = 'none';
tipsDiv.style.display = 'none';
});
infoContainer.addEventListener('click', (e) => {
e.stopPropagation();
});
// Expand button expands the floaty comment box across the whole height of the screen
expandBtn.addEventListener('click', () => {
expandBtn.style.display = 'none';
collapseBtn.style.display = 'none';
uncollapseBtn.style.display = 'none';
minimizeBtn.style.display = 'block';
floatyContainer.style.height = '100%';
});
// Minimize button brings the floaty box back to a minimal size after being expanded fully
minimizeBtn.addEventListener('click', () => {
minimizeBtn.style.display = 'none';
uncollapseBtn.style.display = 'none';
expandBtn.style.display = 'block';
collapseBtn.style.display = 'block';
floatyContainer.style.height = '30%';
});
// Collapse button hides the box & footer and only displays the header
collapseBtn.addEventListener('click', () => {
uncollapseBtn.style.display = 'block';
minimizeBtn.style.display = 'none';
expandBtn.style.display = 'none';
collapseBtn.style.display = 'none';
floatyBox.style.display = 'none';
footer.style.display = 'none';
floatyContainer.style.height = '36px';
});
// Uncollapse button shows the floaty comment box again
uncollapseBtn.addEventListener('click', () => {
uncollapseBtn.style.display = 'none';
minimizeBtn.style.display = 'none';
expandBtn.style.display = 'block';
collapseBtn.style.display = 'block';
floatyBox.style.display = 'block';
footer.style.display = 'flex';
floatyContainer.style.height = '30%';
});
// Tapping on header also shows the floaty comment box
header.addEventListener('click', (e) => {
if (e.target === header) {
floatyBox.style.display === 'none' ? uncollapseBtn.click() : collapseBtn.click();
}
});
// Jump to real comment box
downBtn.addEventListener('click', () => {
localStorage.setItem("scrollY",window.scrollY); // Save scroll position to local storage
document.querySelector('textarea[name="comment[comment_content]"]').scrollIntoView();
});
// Go to previous scroll position
upBtn.addEventListener('click', () => {
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();
saveToLocalStorage(floatyBox.value);
});
commentBox.addEventListener('input', () => {
if (commentBox.value !== floatyBox.value) {
floatyBox.value = commentBox.value;
updateCharCount();
}
});
// 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 (floatyBox) floatyBox.value = ''; // Clear floaty box
localStorage.removeItem(storyKey); // Clear the saved draft
});
}
// Clear local storage draft after being cleared
setInterval(() => {
if (floatyBox.value === '' && commentBox.value === '') {
localStorage.removeItem(storyKey);
}
}, 1000);
}
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
})();