// ==UserScript==
// @name AO3 Floaty Comment Box (Responsive)
// @namespace http://tampermonkey.net/
// @version 1.9.2
// @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;
`;
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 « » 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 ⇓ button).</li></ul>' +
`<p>📞 <strong>Contact:</strong> <a href="mailto:[email protected]">Email me</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>.</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 you want to share?</li>'+
'<li>❓ Did this chapter give you any questions you can'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 = '« 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%;
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
})();